MCP Apps: Because Your Users Deserve More Than a Wall of Text
How we replaced walls of text with interactive components — and why your MCP server should too
MCP what?
This is an extension of the MCP standard, which allows serving interactive components to the user that live within the conversation context.
If you don’t know what MCP is, then go and read more here https://medium.com/@sodkiewiczm/mcp-in-simple-words-62eb6769a0a6
Why should I care?
Because it’s cool! Seriously, it is a game-changer for presenting data to your users. Let’s focus on a specific example.
Airports component
The customer is prompting that he is interested in the airports of some amazing airline. Let’s say Ryanair. With the typical MCP approach, we could serve some response. Let’s say it would be a JSON with a list of airports. This is not very user-friendly. LLMs will load it and represent it to the user. How? Hard to tell. We are building MCP, so we do not control the user or system prompt. Thanks to the MCP Apps, we are getting consistent data representation and our brand identification.
Apart from that, in case the user sees a wall of text or a truncated version of the list of >220 airports. In most cases, it would be a pretty poor user experience. Using MCP Apps, we can make a perfect representation tailored to the data we serve.
In our scenario, it could be represented as a map. Super easy to digest, with zoomable and interactive geographic data. Let’s take a look.
Apart from that, we can provide users with a great UX by serving an interactive component that displays all outgoing routes for the selected airport. Would you like to see all outgoing routes when you click on a specific airport? That is the way to go.
There are already prepared methods, so you can invoke tools from your MCP Server in a very simple way:
app.callServerTool({
name: 'locate-get-routes',
arguments: { departureIataCode: code },
})Take a look at the video below.
User interactions context
A great thing about using MCP Apps is that we can communicate user interactions to the context and propagate prompts to the chat context.
There is a method in the SDK called updateModelContext (docs), which can be used with something as simple as this code snippet.
app.updateModelContext({
content: [
{ type: 'text', text: 'User is viewing routes from airport: ' + code}
]
})Of course, normal prompting good practices can be followed here, and you can add many more details. This is also a way to pass info about errors your app encountered during execution, if relevant.
Another option is to communicate with the model by sending follow-up messages. So after clicking a button by the user, you can populate a prompt (or even send it straightaway, it depends on the client implementation) with
app.sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Show me routes from ' + code }],
})You could send a much more detailed prompt to steer the conversation in a specific direction, rather than relying on users' prompting skills.
Deep-linking
The user might show interest in a specific type of destination. In that situation, we can render a card component containing all the trip details, with a deep link to book it on your website.
Your MCP App is rendered within an iframe, so opening links might be tricky. There is a handy utility so you don’t have to sweat requesting opening links. In a simple way:
app.openLink({ url: btn.dataset.url });Keep in mind that API requires you to handle errors of all of those app commands
Pricing calendar
What if we want to present multiple offers? If the user decides to book a flight on a specific route rather than anywhere, they might want to see the full calendar with fares. Supporting that functionality using only text is… suboptimal (or rather ugly), and it’s not interactive. Definitely, it’s not user-friendly. There is a solution for it using MCP Apps — we can just render a calendar with prices. Within the component, we can handle month changes and clearly show the user our offer at a glance, rather than showing too much information. Every item can deep-link to booking a specific flight.
When a user wants to view a broader date range, this MCP App could render an interactive component that lets the user view more data.
How does it work on the backend?
Initialization
In order to support MCP Apps your server has to add the capability to support mcpApps
Whenever a client initiates communication by sending an initalize request, it sends a request that advertises its capabilities. The request might look like this
{
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": [
"text/html;profile=mcp-app"
]
}
}
},
"clientInfo": {
"name": "Local App",
"version": "1.0.0"
}
},
"jsonrpc": "2.0",
"id": 0
}
Then the server responds to that call with information
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": {}
},
"experimental": {
"mcpApps": {}
},
"resources": {},
"tools": {
"listChanged": true
}
},
"serverInfo": {
"name": "<server name>",
"version": "1.0.0"
},
"instructions": "<Some instructions>"
}
}Important:
experimental.mcpAppsis the legacy MCP-UI pattern andextensionsis the current stable spec
Tools capabilities
When your client is going to list tools, it is important that you inform the MCP client about the resourceUri with information on how to render your tool response. It is advertised through _meta.ui.resourceUri An example tool item could look like that:
Information about resourceUri is duplicated on purpose, take a look “Clients support and standard updates” part of this article
{
"_meta": {
"ui": {
"resourceUri": "ui://show-ryanair-map"
},
"ui/resourceUri": "ui://show-ryanair-map"
},
"annotations": {
"title": "Get Active Airports",
"readOnlyHint": true,
"destructiveHint": false,
"openWorldHint": false
},
"description": "<desc>",
"inputSchema": {
"properties": {
"<property name>": {
"description": "<field description>",
"type": "string"
}
},
"required": [],
"type": "object"
},
"name": "locate-get-active-airports"
}Your server must register the resource under the name that will appear in the MCP tool response. We are going to use ui://show-ryanair-map . If you are going to call any APIs for resources or assets, it is important that you have csp registered on your resource definition.
{
"uri": "ui://show-ryanair-map",
"mimeType": "text/html;profile=mcp-app",
"_meta": {
"ui": {
"csp": {
"resourceDomains": [
"https://assets.mydomain.com"
]
}
}
},
"text": "<your html content here>"
}and then you have to EXTEND your regular MCP tool response by _meta.ui.resourceUri pointing to the ui resource, and to add structuredContent that will be passed to your UI component, like in the snippet below.
The great thing about this approach is that it can just extend MCP. Since it’s backward compatible, in case a client does not support MCP Apps, it can just render a response just like for the regular MCP using LLM.
{
"_meta": {
"ui": {
"resourceUri": "ui://show-ryanair-map"
}
},
"content": [
{
"type": "text",
"text": "some text response"
}
],
"structuredContent": {
"markers": [
{
"code": "AAA",
"name": "airport name",
"city": "airport city",
"country": "airport country",
"latitude": 21.37,
"longitude": 21.37
},
...
]
}
}UI Component
In the simplest possible form, without any integrations, the served resource could look like this:
import { App } from '@modelcontextprotocol/ext-apps';
function renderMap(data) {
...
}
const app = new App({
name: 'ryanair-map',
version: '1.0.0',
});
app.ontoolresult = (result) => {
if (result.structuredContent) renderMap(result.structuredContent);
};
app.ontoolinput = (params) => {
if (params.structuredContent) renderMap(params.structuredContent);
};
app.connect();Clients support
There are many clients supporting MCP Apps at the moment. You can check them here. I was using it with VS Code, Claude, and MCP JAM.
MCP JAM is the way to go during development, in my opinion. At least it’s my favorite one. It is like @modelcontextprotocol/inspector on steroids. It supports local resources, multiple ways to invoke your MCP server (even within a conversation), a much better UI and UX, full debugging scope, an option to make CSP strict or not, and a full history of all versions of rendered components, which makes DevEx much better.
Quirks during building
Templates caching
Rendering mechanisms might cause a few issues. Especially not intuitive, one is building the MCP App for the Claude desktop. Since it’s caching resource templates, it requires restarting the whole app every time the UI component changes. It’s a headache. If you want to test it directly with Claude, I recommend using the browser version because it’s easier to refresh.
Another interesting thing is that whenever you open a conversation from the past, it will be “re-rendered” to reflect any changes made to the component. It might be tricky for backward-compatibility reasons, as well as due to tool name changes and server downtime.
Development access
Currently, in Claude extensions, you can only point to public URLs. It might be annoying, especially if you are building a product on a machine locked to the internal company network, where ngrok and other tunnels won’t work.
In those cases, integration with extensions must be tested against a standard public endpoint. It might be feasible once you know your integration works well and it is publicly available.
Before that, I would definitely recommend using MCP Jam! It is really amazing software for building your MCP Apps. It supports pointing to local resources from the app, making development smoother and iterations faster.
Clients support and standard updates
Claude and MCP JAM support both legacy and new contract standards. There were changes to the specs, so some clients, depending on their version, might support only the old/new standard, or both.
Initialize response change
For backward compatibility with MCP-UI, you can support both formats in the initialization response, as it is stated in the current spec, “The MCP-UI project serves as a reference implementation demonstrating the core concept, though it uses pre-SEP patterns.”
"experimental": { "mcpApps": {} }along with the stable specification version
"extensions": { "io.modelcontextprotocol/ui": {} }Tool advertisement change
There was a change in the advertising resourceUri in the spec here, so it changed from
"_meta": { "ui/resourceUri": "ui://show-ryanair-map" }to
"_meta": { "ui": { "resourceUri": "ui://show-ryanair-map" } }So, for the time being, until the situation stabilizes, it’s worth supporting both formats. This was done to be backward compatible with MCP-UI, AFAIK. It won’t hurt to support both standards initially and save some nerves on debugging.
Summary
I hope I got you excited about MCP Apps. Especially if that is your first contact with the topic. MCP Apps bring something the AI chat world has been quietly missing. Real UX. They bring life to text-dominated AI chats, which I believe are becoming overwhelming for users these days. Your users really deserve something better! You can also onboard them much more easily when they use components that feel familiar to them, rather than trying to meet all interaction requirements through the chat interface.
Let me wrap it up. MCP Apps can help you with:
- Improving on determinism
- Providing great UX to your users who might be tired of text-based solutions
- Reducing reliance on users’ prompting skills
- Providing your brand touch to your users
- Universal solution supporting multiple clients
Full spec details: https://modelcontextprotocol.io/extensions/apps/overview
