Sitemap
Data Science Collective

Advice, insights, and ideas from the Medium data science community

Stop Returning JSON. Start Shipping UI with MCP Apps

MCP was already good at giving agents capabilities. MCP Apps finally gives humans a proper interface to those capabilities.

17 min readSep 14, 2026

--

Press enter or click to view image in full size

Before we start!🦾

If this piece gives you something practical you can take into your own system:
👏 leave 50 claps (yes, you can!) — Medium’s algorithm favors this, increasing visibility to others who then discover the article.
🔔 Follow me on Medium and LinkedIn for more deep dives into agentic systems, LLM architecture, and production-grade AI engineering.

For most of MCP’s life, the interaction model has been simple: an agent calls a tool, the tool does something, the server returns text or structured data, and the model explains the result to the user.

Press enter or click to view image in full size

And for a lot of use cases, that’s perfectly fine. If I ask “how many active deployments do we have?”, I don’t need a frontend application for that. The agent calls get_deployments(), gets back {"total": 12, "healthy": 10, "degraded": 2}, and answers in a sentence. Done.

But things get more interesting the moment the user wants to interact with the result, not just read it. What if I want a dashboard? What if I want to approve something? What if I need a table with filters, a configuration form, a chart, a deployment control panel, a multi-step workflow, a human-in-the-loop approval? For a long time, MCP itself gave us no standardized way to solve that across hosts. Now it does. It’s called MCP Apps, and I think it changes what an MCP server can represent.

MCP servers were mostly machine interfaces

The original MCP mental model was agent-oriented. A server exposes tools like search_projects(), create_ticket(), restart_service(), get_customer() — the model discovers them, calls them, gets data back, and the human gets whatever the model turns that data into. That's powerful, because it means we expose operations through a standard protocol instead of writing a custom integration for every agent. But it has one important limitation: these capabilities were designed for machines, and the human is one step removed from the actual result.

BTW, you can familiarize yourself with my article about operations “AI Agents Should Think in Operations, Not Commands”.

That’s fine right up until the interaction is inherently visual. Ask “show me all production services,” and getting back a JSON blob with statuses, CPU, and memory numbers is technically correct — but a small dashboard with progress bars, a status badge, and Logs/Restart/Scale buttons that actually work is a lot more useful. That’s the gap MCP Apps is trying to close.

So what exactly is MCP Apps?

MCP Apps is an extension to the Model Context Protocol that lets MCP servers ship interactive user interfaces alongside their tools — not screenshots, not Markdown pretending to be UI, but actual HTML/JavaScript applications rendered inside an MCP host. The official documentation describes the pattern as tools declaring ui:// resources, hosts rendering those resources in sandboxed iframes, and the host passing tool data into the UI while letting the UI call tools back through the host.

MCP App = MCP Tool + UI Resource + Host/View protocol

A tool still does exactly what it always did — calls APIs, queries databases, runs business logic, returns structured data. It just can additionally say: “I have a UI for presenting this result.” That UI itself is provided as an MCP resource, for example ui://services/dashboard, and that resource can contain a complete frontend application — HTML, CSS, JavaScript, React, charts, forms, buttons, whatever it needs. Which gives us something MCP previously didn't standardize across hosts: a machine-facing capability and a human-facing interface for the same operation.

Where did MCP Apps come from?

MCP Apps is very new, and that context matters. If you were actively building MCP servers throughout 2025, you didn’t miss some hidden feature — it simply didn’t exist yet as an official standard.

November 21, 2025. The MCP maintainers introduced SEP-1865, the proposal for the MCP Apps extension, developed together with the MCP-UI creators and maintainers from OpenAI and Anthropic. This didn’t appear out of nowhere — MCP-UI and OpenAI’s Apps SDK were already solving similar problems separately, and the issue was interoperability: a server developer could need one UI integration for one host and a completely different implementation for another. MCP Apps set out to standardize that into one server exposing tools, data, and UI that any compatible host can render. (Official announcement)

January 26, 2026. A little over two months later, the MCP maintainers announced that MCP Apps was live as the first official MCP extension, explicitly described as ready for production. By that point the proposal had turned into a refined specification, a matured SDK, and real host implementations — the January announcement named ChatGPT, Claude, Goose, and Visual Studio Code as clients that had already shipped support. (Official announcement)

July 28, 2026. The MCP Specification Release Candidate formalized the extension model further. Extensions got their own identifiers, a client/server capability-negotiation model, dedicated repositories, independent versioning, and a formal Extensions Track — and MCP Apps was explicitly listed among the official extensions. This release also made one security property clear: UI-initiated actions still go through the host and the same JSON-RPC foundation, so there’s one control path whether the trigger is Agent → Tool or Human → Button → Tool, rather than UI code bypassing the MCP host. (Official release)

September 11, 2026. AWS published a practical example showing an MCP App deployed on Amazon Bedrock AgentCore. The important part isn’t that MCP Apps became an AWS feature — it didn’t, it remains a host-agnostic standard. AWS just showed one production deployment pattern: an AI host talking to an AgentCore Gateway, which routes into an AgentCore Runtime, which runs the MCP server exposing both tools and UI resources, with the actual business logic sitting in Lambda and DynamoDB behind it. Their sample demonstrates the experience in ChatGPT while noting the same server works the same way in Claude and any other host that supports the extension. (AWS example)

Who actually supports MCP Apps?

Worth being precise here: supporting MCP is not the same thing as supporting MCP Apps. A client can support normal tools and resources without implementing the Apps extension at all. The January 2026 announcement named ChatGPT, Claude, Goose, and VS Code as having shipped support, and the current docs describe Apps rendering inline in Claude, ChatGPT, and other compliant clients — while explicitly noting that host support varies. That’s the correct assumption to design around: you shouldn’t build an MCP server as if every client automatically understands Apps. The requirement is simply Server supports MCP Apps + Host supports MCP Apps = Interactive UI, and a well-built server falls back gracefully to text or structured content when the host doesn't render the App.

The one question that actually matters

When I first looked at this pattern, one question mattered more than everything else: if the UI resource is just static HTML, how does dynamic tool data get into it? Tool output changes constantly — CPU is 21% now, 87% ten seconds later — and we obviously can’t regenerate a whole frontend application every time a tool runs. And we don’t have to, because the trick is that the UI and the data are separate things. Think of the tool as the data, the resource as the presentation, and the host as the glue connecting them. The tool returns dynamic data, the resource returns the application capable of rendering that type of data, and the host wires the two together at runtime.

How MCP Apps actually work

Take a tool like get_servers(), which returns something like a list of server objects with id, name, status, CPU, and memory — and whose definition additionally points to a UI resource at ui://servers/dashboard. The lifecycle looks like this:

The model calls get_servers(). The server does whatever business logic it needs — hits a database, AWS, Kubernetes, an internal service — and returns structured data. The host then notices the tool's metadata points to ui://servers/dashboard, so it knows this result can be presented as an interactive view. It performs an MCP resource request (resources/read) against that URI, and the server returns the actual frontend application — in practice this might be a bundled React app, a Vue app, or plain JavaScript. Critically, the current server data is not baked into that HTML; the UI just knows the contract it expects (servers[].id, servers[].status, and so on).

The host then renders that application inside a sandboxed iframe — an important boundary, since we’re executing UI code delivered by an MCP server and don’t want it getting unrestricted access to the host’s DOM, session, or credentials. Finally, the host delivers the actual tool result into the running view. The official architecture does this over JSON-RPC via postMessage between the host and the sandboxed view:

Press enter or click to view image in full size

The frontend receives the same data any normal frontend would, and renders it exactly the same way. One server gives you one card, fifty servers give you fifty cards — the application doesn’t change, only the data does.

This becomes much easier to reason about once you compare it to a normal web app. Traditional architecture is React making a GET /api/servers call, getting JSON back, and rendering it. MCP Apps is the agent triggering tools/call, the tool returning JSON, and the host passing that same JSON into a React app running inside an iframe. The biggest difference is that the UI doesn't necessarily make the initial request itself — the agent already triggered the operation, the host already has the result, and it just hands that result to the application.

But MCP Apps are not just prettier tool results

This is where it gets genuinely interesting: the UI can also talk back through the host, which means the application can call other MCP tools directly. Picture the same server dashboard, now with [Logs] [Restart] [Scale] buttons. When a user presses Restart, the application requests restart_server(server_id="srv-1") — but the iframe never talks to the MCP server directly. Instead the App calls the host, the host issues tools/call, and the MCP server executes restart_server() the same way it would for the agent:

MCP App → call tool → Host → tools/call → MCP Server → restart_server()

The JavaScript behind the button just needs to know the tool name and its input contract, something like:

async function restartServer(serverId) {
return app.callServerTool({
name: "restart_server",
arguments: { server_id: serverId }
});
}

Both the agent and the human-facing application now use the same capability layer. That’s the part I find far more interesting than simply putting a chart inside a chatbot.

One capability, two interfaces

Without this pattern, architectures tend to drift toward exposing the same business capability twice — a POST /api/deployments/restart REST endpoint for the frontend, and a separate restart_deployment() tool for the agent. Now you're maintaining two integration surfaces for one operation. With MCP Apps, both the agent and a human pressing a button can call the exact same restart_deployment operation, with authorization, validation, observability, and business logic sitting behind that one operation instead of two. That's a much bigger architectural consequence than "MCP now supports widgets."

A production example: human approval inside an agent workflow

Consider something more realistic. A user asks the agent to create a user story for a feature. The agent pulls context with get_feature_context(), and the LLM drafts the story with its acceptance criteria. Normally the workflow just ends there — the user gets text back, and maybe responds "looks good" or "change the second acceptance criterion," and the backend has to infer whether that means approval. That's fragile.

With MCP Apps, the agent instead calls something like present_user_story_for_approval(...), and the App renders the story with an explicit Reject / Approve pair of buttons. Now there's a real human-in-the-loop boundary: Approve calls approve_user_story(story_id="US-483", version=1), Reject calls reject_user_story(story_id="US-483", version=1, reason="..."). The whole flow becomes a state machine rather than a guess:

Press enter or click to view image in full size

This is much more than decoration — the interface becomes an actual part of the agent’s workflow, and the approval is auditable in a way “type YES to continue” never was.

A couple of production details are worth calling out here. First, the App shouldn’t try to scrape the chat message above it to recover the story text — that creates a brittle dependency on the host’s rendering implementation. The cleaner pattern is to pass the story explicitly as a tool argument (present_user_story_for_approval(story_id, version, story)), so the view already has the state it needs and the conversation rendering stays separate from the actual workflow state. Second, pass IDs and versions rather than whole objects. If the story gets revised to version 2 while an old approval card is still sitting in the conversation, and the button just resubmits the full text, you can end up approving stale state. Submitting story_id plus version lets the backend reject a stale approval and ask the user to review the latest draft instead — the conversation is UI, the backend remains the source of truth.

Not every button needs to be a tool the model sees

One more useful idea in the MCP Apps model is tool visibility. A dashboard might need a handful of UI-only operations — pagination, refreshing a table, changing sort order, adjusting a chart’s date range, saving a filter — and there’s little reason to put all of those microscopic interactions into the LLM’s tool context. An App can have its own UI-specific operations while the model keeps a smaller, cleaner capability surface. As an MCP server grows, tool-context size and tool-selection quality become real concerns, and this separation keeps the model from having to reason over things it was never meant to touch.

Get Andrii Tkachuk’s stories in your inbox

Join Medium for free to get updates from this writer.

This is more than a convention — the spec gives you a concrete mechanism for it. A tool’s _meta.ui.visibility field can be set to ["model", "app"] (the default, visible to both), or narrowed to just ["app"]. When a tool is marked app-only, the host must not include it in the tool list it hands to the model at all — it simply doesn't exist from the agent's point of view, and it can only ever be called from inside the running App view. So it's not only "the model doesn't need to see pagination" as a design preference — you can now genuinely split one MCP server's tools into two access tiers: some callable only programmatically by the agent, some callable only from inside the App, and some open to both. That's a real access boundary, not just tidier tool-selection.

The communication can flow the other way too. In the rejection example above, when a user clicks Reject and types a reason, that reason doesn’t have to stay locked inside the App — MCP Apps provides a way for the App to update model context (depending on host capabilities and permissions), so the next model turn already understands why the story was rejected and can generate a better version 2 without the user manually restating everything in chat.

MCP Apps is not a new core primitive

Worth clarifying, because the name invites the wrong mental model. MCP Apps doesn’t turn Tools/Resources/Prompts into Tools/Resources/Prompts/Apps, as if Apps were a fourth core primitive. It’s an extension — it builds on existing MCP concepts and adds a standardized relationship between a tool and a UI resource, plus a standardized protocol between server, host, and view. If you already understand tools and resources, MCP Apps isn’t a different protocol bolted on top; it’s an interactive layer built around the one you already know.

What changes if you own the chat application

This matters a lot if you’re building your own agent platform rather than plugging into an existing MCP Apps-compatible host. Your chat application is effectively the MCP host — so it also needs to understand MCP Apps: detect UI metadata, read the associated resource, render the app in a sandbox, deliver tool input/output to the view, proxy allowed tool calls from the view back to the server, negotiate capabilities, and enforce permissions. That’s a real architectural surface, not a checkbox.

Press enter or click to view image in full size

There are genuinely three participants here — MCP server, MCP host, and MCP App view — and the current official SDK even separates these roles explicitly into view developers, host developers, and server authors.

That SDK is currently centered on the @modelcontextprotocol/ext-apps TypeScript packages, which does not mean your business logic has to become a Node.js application. The wire model still revolves around normal MCP capabilities — tool metadata, resources, structured content, MCP requests — so a Python/FastMCP server fits fine as long as it can expose the metadata, resources, and structured output an Apps-compatible host expects. Don't confuse "the official helper SDK is TypeScript" with "MCP Apps requires your entire backend to be TypeScript." Your view will naturally be web technology; your business layer and existing services can stay exactly where they are.

Security cannot be an afterthought

Once an MCP server can deliver executable UI, the security model becomes critical. The architecture already helps here — views run in sandboxed iframes and communicate through the host rather than getting unrestricted access to the parent application — but from an enterprise architecture perspective, I’d still treat every App as untrusted UI, especially when an App can trigger something like restart_service(), delete_resource(), approve_payment(), or deploy_to_production(). For destructive operations I'd add an explicit confirmation dialog, but the confirmation dialog is not your only protection — the backend still needs authentication, authorization, input validation, policy enforcement, audit logging, idempotency, version checks, and rate limiting behind it. The UI is not the trust boundary. The backend is.

Where MCP Apps actually make sense

I wouldn’t add an MCP App to every tool — that would be a mistake. If the result is 42, just return 42; if someone asks what version production is running, they don't need React for that. MCP Apps earns its keep when the interaction itself has structure. A few strong production candidates:

Operations dashboards — services, deployments, infrastructure, logs, metrics, jobs, queues: anywhere users want to inspect something and then act on it.

Approval workflows — approve/reject, accept/request changes, deploy/cancel, publish/keep draft. Probably the single strongest enterprise use case.

RAG and enterprise knowledge search — instead of returning twenty search results as plain text, an App can give you filters, checkboxes, and a “Compare Selected” action, while the agent keeps handling the natural-language reasoning around it.

Agent governance — natural language is great for “which agents currently have access to production Salesforce?”, but a structured registry view is usually a better place to review permissions and approve changes. The two models complement each other.

Forms and configuration — asking a user to say “configure CPU to 2, memory to 4GB, region to us-east-1, replicas to 3” in plain English is worse than just giving them a form. MCP Apps lets the agent bring that form into the conversation exactly when it’s needed.

Don’t build one app per tool

Another decision I’d avoid is mapping tools to apps one-to-one — tool_1 → app_1, tool_2 → app_2, and so on. Think in terms of capabilities or domains instead. A single "Deployment Management" App can sit on top of get_deployment, get_logs, restart_deployment, scale_deployment, and rollback_deployment — one tool acts as the entry point that surfaces the App, and once it's loaded, the App can work with the whole related family of tools. That produces a much more coherent UI and a cleaner MCP architecture than a pile of single-purpose apps.

The bigger architectural shift

The thing I find most interesting about MCP Apps isn’t the iframe. It’s that we’ve spent a lot of effort designing operations for agents, and now those same operations can expose a standardized human interaction layer on top:

OPERATION → Machine Interface (MCP Tool) + Human Interface (MCP App)

If you’re already thinking in terms of agent plugins rather than isolated MCP servers, this gets even more interesting — a Salesforce capability could ship instructions, MCP tools (search_accounts, create_opportunity, update_lead), permissions, evals, and a set of MCP Apps (an account browser, an opportunity form, a pipeline dashboard) all as one package. The capability stops being just a bag of tools and becomes a complete interaction surface for both agents and humans.

And notably, the agent itself doesn’t need to know any of this exists. It doesn’t need to know React, or understand an iframe, or know how buttons get rendered — it just calls get_services(...) or present_user_story_for_approval(...), and the tool metadata tells the host that an interface exists. The UI concern stays in the UI layer, the reasoning concern stays with the agent, and the business logic stays in the backend — exactly the separation you want in a production architecture.

How I’d introduce this into an existing system

If I already had a production agent platform with a custom chat, agents, and a FastMCP server, I wouldn’t redesign the system to adopt this. I’d start with one read-only capability — something like get_agents() returning a small list — and associate it with the smallest possible view (ui://agents/list): just a list of names, statuses, and tool counts, no buttons yet. The first goal is simply proving the full loop end to end: agent calls tool, host detects the UI, reads the resource, renders the iframe, and the tool result actually reaches the view.

Once that works, I’d add one interaction — a Refresh button — then one real operation like Disable Agent, then authorization, then audit logging, then move on to more complex Apps. That gives you incremental adoption without touching your agent’s reasoning architecture at all.

Final thought

For a long time, MCP answered one important question: how can an AI agent interact with external systems through a common protocol? MCP Apps adds another one: how can the human interact with those same capabilities without leaving the conversation? That’s a bigger change than it might look like at first.

The old model was agent → tool → JSON → text. The new model is a single capability branching into two interfaces — an agent talking to an MCP tool, and a human talking to an MCP App — both ultimately hitting the same business logic underneath. Agents still reason, tools still execute operations, but when the interaction needs structure, the protocol can now bring an actual interface into the conversation: dashboards, forms, approvals, visualizations, configuration screens, human-in-the-loop workflows — all sitting on top of the same MCP capability layer your agents are already using.

That’s why I don’t think of MCP Apps as “MCP with nicer responses.” I see it as something bigger: MCP servers are evolving from machine-facing integration endpoints into portable interaction layers for both agents and humans.

🧩 For Claude Code people

If you run more than one Claude Code session, you know the feeling: one of them has been waiting on “Do you want to proceed?” for ten minutes, in a window you’re not looking at.

I got tired of that, so I built Unibrow. It turns the MacBook notch into one inbox for every session. Permission requests, Claude’s questions, plans waiting for approval and “done” notices all show up there. Hover, click, keep working.

Press enter or click to view image in full size

It’s free and open source. It runs only on your Mac, it never approves anything on its own, and the terminal prompt keeps working as usual.

Install it with one line inside Claude Code:

/plugin install cc-unibrow --marketplace andriitk/cc-unibrow

🌐 Website · ⭐ GitHub

And that’s a wrap! If you’ve read this far, it probably means you found this article useful or insightful. If that’s the case, consider leaving a few claps or sharing it with your team, please. Thanks for reading! 🚀

--

--

Andrii Tkachuk
Andrii Tkachuk

Written by Andrii Tkachuk

Senior AI Engineer | Applied AI Architect | AI Agents, MCP, RAG, AI Governance, Agent Harness & Сloud-native AI 🧠 https://www.linkedin.com/in/andrii-tkachuk7/

Data Science Collective
Data Science Collective

Published in Data Science Collective

Advice, insights, and ideas from the Medium data science community