How to Build MCP UI: An AI Agent Approval Gate
Here's a situation I think about a lot. You've built an agent that drafts customer replies, and it's good. So you let it send them. Then one Tuesday it reads "yeah sounds good" in a thread, decides that's a green light, and fires off a refund confirmation to the wrong customer. Nobody approved it. Nobody even saw it.
That's the gap I want to close in this article. We're going to build an MCP App that puts a human approval gate in front of an agent's risky actions. It renders an interactive card right inside the chat (Approve, Edit, or Reject) and the agent only proceeds once you've said so. First half is the what and why. Second half is the actual code. I built this thing, so the second half is the real version, not pseudocode.
What is MCP UI? (And what's an MCP App?)
MCP UI is interactive UI that an MCP server can ship to the client, so a tool renders a real interface inside the conversation instead of returning a wall of text. Think buttons, forms, charts, and cards, all in a sandboxed iframe that the host displays inline.
The mechanism is almost suspiciously simple. A tool declares a UI resource, the host fetches that resource when the tool runs, and then messages flow both ways between your UI and your server. That's it. If you've ever wired up an MCP server with plain tools, you already know 80% of this. The UI is just one more thing the server hands over.
The official name for this pattern is MCP Apps, standardized in the Model Context Protocol and shipped in early 2026. It works across hosts (Claude, ChatGPT, and others), so you're building for an open standard, not painting yourself into one vendor's corner.
MCP Apps vs mcp-ui: which is which
This trips people up, so quickly: mcp-ui is a community SDK for "UI over MCP" that's had a head start and a big following. MCP Apps is the official spec and SDK, the modelcontextprotocol/ext-apps repo. The good news is they've converged. Both link a tool to its UI through the same _meta.ui.resourceUri field, and the mcp-ui packages now play nicely with MCP Apps hosts. This tutorial uses the official ext-apps SDK. If you've already invested in mcp-ui, most of what follows still maps cleanly.
What's a human approval gate, and why do agents need one
A human approval gate is a checkpoint. Before the agent does something it can't easily undo (send the email, issue the refund, publish the post, delete the records), a person sees exactly what's about to happen and decides. This is the practical face of "human in the loop," and it's less about distrust and more about physics. Some actions don't have an undo button.
You might say you already do this. You ask the agent to confirm. The problem is that text confirmation is weak. The agent infers your intent from prose, you can't see the actual artifact it's about to send, and a casual "yep go for it" gets matched against the wrong pending action. Agents are confident. That's sort of the whole problem. An agent that's 95% sure is also 5% about to CC your biggest client on something with a typo in their company name.
A UI gate fixes the weak parts. You see the real email, with the real subject line and the real recipient. You can edit it in place before approving. And the decision comes back as a clean, unambiguous signal instead of a sentence the agent has to interpret. If you've read my piece on Claude Code hooks, this is the same instinct (put a deliberate checkpoint where an agent would otherwise barrel ahead), just moved into the conversation where the human actually is.
What you can actually build with MCP UI (beyond dashboards)
When people first see MCP UI, they immediately want to build a dashboard. Fair. Dashboards are nice. But here's my slightly contrarian take after building a few of these: the dashboard is rarely the highest-leverage thing you can put in front of a model.
The genuinely useful patterns cluster into four buckets:
- Approval gates. The one we're building. A confirmation step before consequential actions.
- Configuration wizards. Forms with dependent fields, where picking "production" reveals options that "staging" hides.
- Live data exploration. A chart or table the user can filter and drill into, instead of a snapshot that's stale the moment it's printed.
- Visual and spatial review. A PDF with the relevant clause highlighted, a map, a calendar of open slots. Anything where position carries meaning.
The reason the approval gate wins, for me, is leverage. It's the one interaction that actually changes how much you can trust an agent with real-world actions. And it's absurdly reusable. The same gate handles an email today, a refund or checkout step tomorrow, a publish action next week. The artifact inside the card changes. The pattern doesn't. If you build software for small businesses, that reuse is the whole game (one component, every client), which is exactly the kind of thing I harp on in my small business AI use cases writeup.
One honest caveat: don't build UI for everything. If the answer is a single number, just return the number. If the task is fully autonomous and nobody needs to touch it, skip the card. And if you find yourself rebuilding your entire SaaS inside a chat window, you've taken a wrong turn somewhere. MCP UI is a seasoning, not the meal.
Where this is heading
Two things make me think this pattern matters more than it looks right now.
The first is portability. Because MCP Apps is a shared standard, the same approval gate runs in Claude and ChatGPT without a rewrite. You build the interaction once. For anyone who lived through the era of building the same integration five times for five platforms, that's a quietly big deal.
The second is that trust is becoming a UX surface. As agents take on more actions with real consequences, the place where human oversight lives is exactly this confirmation layer. I'd bet the approval gate stops being a thing each of us hand-rolls and starts being a standard component people drop in, the way auth stopped being something you wrote from scratch. Building one now is a good way to understand it before it gets abstracted away.
Building the approval gate, step by step
Right, enough philosophy. Let's build it. Stack is TypeScript, React, and the official ext-apps SDK, bundled with Vite. I'll show the load-bearing pieces here and link the full project at the end so you're not reading a 300-line paste.
How an MCP App works under the hood
The flow is four steps, and it helps to hold the whole shape in your head before the code:
- Your tool declares a UI resource (a
ui://URI). - The model calls the tool.
- The host fetches the resource and renders it in a sandboxed iframe. This is the part people mean when they search for "mcp client ui," the client-side rendering of your interface.
- Your UI and your server exchange messages through the host.
The link between the tool and its UI is one field, _meta.ui.resourceUri. Memorize that and the rest is detail.
The server: three tools and a UI resource
The server registers three tools. request_approval is the one the agent calls, and it's the one that carries the UI. submit_approval_decision is called by the card when you click a button. list_approvals is a read-only audit trail, because in a real workflow you want a record of who approved what.
Here's the heart of request_approval, trimmed for readability:
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
registerAppTool(
server,
"request_approval",
{
title: "Request Human Approval",
description: "Pause and ask a human to approve a consequential action before you take it.",
inputSchema: requestApprovalInput, // a small Zod shape: actionType, title, summary, fields[]
_meta: { ui: { resourceUri: "ui://approval-gate/mcp-app.html" } }, // links tool -> UI
},
async (args) => {
const request = createRequest(args); // store it, give it an id
return { content: [{ type: "text", text: JSON.stringify(request) }] };
},
);That _meta.ui.resourceUri is the magic line. It tells the host "when this tool runs, go render that resource." The handler stashes the request (so we can validate the decision later) and returns it as JSON, which the UI will read to draw the card.
The UI resource itself is just your bundled HTML, served back by a resource handler. The tool descriptions matter more than usual here: I spell out, in the description the model reads, that after calling request_approval it must wait and not perform the action until the decision comes back. Models are eager. Tell them to sit.
The view: receive the request, send the decision
The browser side uses the App class from ext-apps. Two methods do almost all the work: one to receive the request, one to send the decision back.
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Approval Gate", version: "1.0.0" });
// Set this BEFORE connect(), or you can miss the first result.
app.ontoolresult = (params) => {
const text = params.content?.find((c) => c.type === "text")?.text;
if (text) setRequest(JSON.parse(text)); // hand it to React state
};
await app.connect();The "set this before connect" comment is not decoration. I learned it the boring way, staring at a card that rendered empty because the result arrived before I'd registered a handler for it. Order matters.
Sending the decision back is one call:
await app.callServerTool({
name: "submit_approval_decision",
arguments: { requestId: request.id, decision: "approved" }, // or "edited" / "rejected"
});From there it's ordinary React. The card has three modes. View mode shows the artifact with Approve, Edit, and Reject buttons. Edit mode swaps the fields for inputs so you can fix that typo before approving. Reject mode asks for a reason. The one touch I'd recommend not skipping: sync to the host's theme with applyDocumentTheme so the card matches light or dark mode automatically. It's a small thing that makes the card feel native instead of bolted on.
State, idempotency, and an audit trail
The server keeps a small store of requests and their decisions. For the demo it's an in-memory Map, which is honest about what it is: fine for one process, gone on restart, useless across replicas. In production you'd back it with Postgres or Redis, and the store's function signatures are deliberately the seam where you'd swap that in.
The rule worth enforcing is that a request can be decided exactly once. Without it, a double-click or a replayed call could approve the same action twice, and "the agent issued the refund twice" is the kind of bug that turns into an email from finance. So the decision recorder checks the request exists and is still pending before it writes anything. Boring, defensive, correct.
Testing it in Claude
The surface that runs a local server and renders its UI is Claude Desktop, via a stdio config. (Custom connectors on claude.ai need a public HTTPS URL, which is a different setup.) Build first, because the server serves the bundled HTML:
npm install && npm run buildThen open Claude Desktop, go to Settings, Developer, Edit Config, and add the server pointing at the project's entry file with the --stdio flag. Restart Claude fully (quit, not just close the window). Ask it to draft something and request your approval, and the card appears inline. The exact steps are in Anthropic's MCP Apps getting-started docs, and the official MCP Apps quickstart is worth a read for the build setup.
Claude Desktop launches your command with a minimal PATH, sometimes npx or tsx installed through nvm or Homebrew just isn't found, and the server fails silently. If that happens, the fix is to use absolute paths (run which npx, paste the full path). For fast UI iteration without restarting Claude every time, run the ext-apps reference host over HTTP and point it at your local server. Tweak there, then do the Claude Desktop run as the real check.
Wrapping up
The thing I want to leave you with is how little code this took for how much it changes. A couple of tools, one bundled view, a store with a single safety rule, and suddenly an agent that used to act on a misread "yes" now stops and shows its work first.
That's the real pattern here, and it outlives this specific example. The card swaps out per use case, the gate stays the same, and because it's built on an open standard it follows you across hosts. Clone the full project on GitHub, change the fields schema to whatever your agent is about to do, and you've got a reusable checkpoint you can put in front of anything risky.
Build the gate before you need it. Future you, the one not writing an apology email, will be grateful.
Related Articles
How to Build an MCP Server with TypeScript
Build an MCP server with TypeScript using the official SDK. Covers tools, resources, error handling, and production deployment for AI integrations.
8 min read
TutorialsBuild an AI SEO Agent in TypeScript with Claude
Code a real AI SEO agent in TypeScript — crawls competitor sites, scores pages with Claude, returns the most relevant content. Full tutorial.
10 min read
TutorialsBuilding Reliable Invoice Extraction Prompts That Handle Edge Cases
Learn how to craft LLM prompts for invoice extraction that handle messy scans, edge cases, and human error—with confidence signals and real-world testing strategies.
10 min read