MCP Apps: Your UI, Their Chat The Model Context Protocol released its 2026-07-28 version, formally locking in the MCP Apps extension that lets servers return isolated, interactive, stateful UIs embedded inline with agent responses. MCP Apps were proposed in November 2025 and went live in January 2026, building on MCP-UI and the OpenAI Apps SDK, and are now supported natively by ChatGPT, Claude, and VS Code GitHub Copilot Chat, with Figma and Slack as launch partners. The core protocol update also adds statelessness (no handshake, no session header, no long-lived connection), new Mcp-Method and Mcp-Name HTTP headers, and Multi Round-Trip Requests replacing server-initiated requests. On the 28th of July, the 2026-07-28 unsurprisingly version of the Model Context Protocol MCP was released https://blog.modelcontextprotocol.io/posts/2026-07-28/ . This comes with a plethora of changes and features long-awaited by MCP Server maintainers. Notable changes in the core protocol include: - Statelessness - no handshake - no session header - no long-lived connection - New Mcp-Method and Mcp-Name HTTP headers - Server-initiated requests replaced by Multi Round-Trip Requests MRTR The headline statelessness change has been a long time coming and makes it much easier for MCP server developers to scale without having to juggle state on their end. However, what I am most interested in is a particular extension of the protocol, whose framework the release notes describe as now being “formally locked in”: MCP Apps . As mentioned, apps are currently an extension, but with the recent MCP roadmap update https://modelcontextprotocol.io/development/roadmap , there are plans to include the Tasks extension in the core protocol eventually, which may mean the same for apps at some point. Since the introduction of agents in the AI space, text has been the main and usually only medium through which they could communicate. Markdown could be rendered and indeed HTML , but these were all LLM-generated. This meant that: - information output in tables could not be guaranteed to be true, - all structures were static, save for what could be handled by HTML and CSS, - and the layout could differ with every agent invocation. For self-developed agents, syntactic correctness could be ensured as part of structured outputs via constrained decoding . However, this was much more difficult, if not impossible, to guarantee if a third-party agent, such as Claude or ChatGPT, used your MCP server. If an agent called a tool which produced a lot of data, it was mostly up to the agent on the other side to decide how to display it in terms of layout, depth, and to make sure it got all of the values right in the first place. With MCP Apps, a whole new world has opened up not just in terms of being able to make sure the user sees the right information in the right way, but also in the level of interactivity a user can have in the middle of and around the chat box. Now, alongside tool call responses, MCP Apps permit an isolated, interactive, and stateful UI to be returned from server to client which can then be embedded in-line with the agent’s answers. Concrete examples of potential use cases include custom dashboards using your enterprise styling, interactive data tables, and forms for users to interact with systems outside the chat. Timeline Building on MCP-UI https://github.com/MCP-UI-Org/mcp-ui and the OpenAI Apps SDK https://openai.com/index/introducing-apps-in-chatgpt/ , MCP Apps were proposed in November 2025, went live in January 2026, and their extension framework is now formalised as of the 2026-07-28 protocol version. Despite this long timeline relative to the speed of AI progress nowadays , support has been lacklustre and is still catching up. ChatGPT’s original implementation was the OpenAI Apps SDK, but it now supports MCP Apps natively. Other clients which support MCP Apps today include Claude and VS Code GitHub Copilot Chat. Initially apps built using MCP Apps and the OpenAI Apps SDK were the preserve of only large companies – Figma and Slack as MCP Apps launch partners, and Booking.com and Spotify among earlier Apps SDK adopters. However, with framework support getting better, it’s becoming much easier for smaller companies and developers to make their own. Next, I’ll show you how to start working with MCP Apps utilising FastMCP https://github.com/PrefectHQ/fastmcp and Prefab https://github.com/PrefectHQ/prefab . Usage First let’s set up a simple MCP server: python from contacts import search from fastmcp import FastMCP mcp = FastMCP "contacts" @mcp.tool def search contacts query: str - list dict : """Find contacts matching a free-text search of name, role, company or department.""" return search query if name == " main ": mcp.run As the function name lets slip, the above permits searching for contacts in a database I have imported the search logic itself, as it is not relevant to the discussion at hand about MCP . Decorations provided by FastMCP allow for setting up an MCP quickly and simply, with the docstring acting as a description for the tool. Whilst the above would work as a barebones MCP server, it is not particularly user-friendly. It only returns the data to the AI agent directly, and from there it is up to the agent about what to display and how to display it: For two similar questions, the AI has returned slightly different data in slightly different formats. Whilst this may merely count as an inconvenience, the main issue is hidden: how can you be sure that the data that has been output, token-by-token by the AI, is correct? In a bid to resolve this, let’s turn to MCP Apps: python from contacts import search from contacts 2 ui import contact carousel from fastmcp import FastMCP from fastmcp.tools import ToolResult mcp = FastMCP "contacts" @mcp.tool app=True def search contacts query: str - ToolResult: """Find contacts matching a free-text search of name, role, company or department.""" results = search query names = ", ".join contact "name" for contact in results return ToolResult content=f"{len results } contacts: {names}", structured content=contact carousel query, results , if name == " main ": mcp.run The difference in the code is minimal: - app=True in the tool decoration, - returning ToolResult encompassing content and structured content . Setting app=True lets FastMCP know that you wish for this tool to produce an MCP app alongside returning the data to the AI as before. This combination is expressed as ToolResult with content the same as what was returned in the prior snippet whereas structured content contains the new, complementary app: contact carousel shared at end carousel-code . Once again, the output from the AI itself differs both in content and layout. However, now we have the deterministic MCP app which provides all the content we define it to and in the shape we design. Note also that this carousel is not just a static, rendered markdown structure, but a bespoke HTML and JavaScript component, displayed in-line in the ChatGPT chat interface. This means not only can it follow whatever shape you like, it can also take on whatever enterprise styles you desire regardless of whether they clash with ChatGPT , and it can also have increased interactivity to improve user engagement. Whilst this change has ameliorated the experience greatly in my view, anyway , it is still read-only. It tends to be that a UI talks to a backend via API calls, turning a simple display into somewhere a user can act. By default, MCP Apps are effectively sandboxed the ‘CSP off’ badge is rendered whilst the app is in developer mode . This can be altered in configuration, but the intended path is to define additional MCP tools instead – sticking to the one protocol. Direct API calls would require their own credentials, whereas handily tool calls inherit those of the server. Another useful feature is that tool visibility is readily configured. This allows for risky operations to be executed by a human user through the app, but not directly invoked by the AI. python from contacts import search, update from contacts 3 ui import editable carousel from fastmcp import FastMCP from fastmcp.apps import AppConfig from fastmcp.tools import ToolResult mcp = FastMCP "contacts" @mcp.tool app=AppConfig visibility= "app" def update contact contact id: int, email: str, phone: str - str: """Save edits to a contact. Callable by the app, hidden from the model.""" return update contact id, email=email, phone=phone @mcp.tool app=True def search contacts query: str - ToolResult: """Find contacts matching a free-text search of name, role, company or department.""" results = search query names = ", ".join contact "name" for contact in results return ToolResult content=f"{len results } contacts: {names}", structured content=editable carousel query, results, on save=update contact , if name == " main ": mcp.run In the snippet above, an extra tool has been added: update contact . Unlike search contacts , it renders no UI of its own, but its visibility is restricted to the app, so the model never sees it. The only other change compared to before is that contact carousel has become editable carousel shared at end carousel-code , which in turn takes the new tool as an argument. That’s all it takes to allow for write actions to be executed from the MCP app: Now, not only can you see the returned data in the same format, in the same depth, and with guaranteed correctness, but you can also alter it, without leaving such dangerous operations to an unaccountable model, and without leaving the chat. This is particularly good for non-technical users, who may not recognise when an AI agent plans to take a perilous, mutating action. This simple showcase affords just a glimpse of the capabilities of MCP Apps. No longer do you have to make a bespoke agent to escape the dominance of fuzzy, generated markdown; you can now hand work to an AI agent and be assured that what the user sees is what you defined. What I most look forward to is this: bespoke features inside non-bespoke agents – your own tools, UIs, and most importantly, guarantees, in someone else’s chat window. Carousel Code I extracted out the code for the carousels, as they weren’t the focus. Here is the full code for them: Contact Carousel Code python from prefab ui.app import PrefabApp from prefab ui.components import H2, Card, CardContent, CardDescription, CardHeader, CardTitle, Carousel, Column, Icon, Row, Text, def field icon: str, value: str - None: with Row gap=2, css class="items-center text-sm text-slate-700" : Icon icon, size="sm" Text value def card contact: dict - None: """One contact as a card.""" with Card css class="w-full text-left" : with CardHeader : CardTitle contact "name" CardDescription f"{contact 'job title' } · {contact 'company' }" with CardContent : with Column gap=2 : field "mail", contact "email" field "phone", contact "phone" def contact carousel query: str, results: list dict - PrefabApp: """The whole view: a heading and one card per contact.""" with PrefabApp as app: with Column gap=3, css class="w-full p-3 text-left" : H2 f"{len results } contacts for “{query}”", css class="text-base font-bold", with Carousel visible=1.2, gap=12, loop=False, show dots=True : for contact in results: card contact return app Editable Carousel Code python from collections.abc import Callable from prefab ui.actions import CallTool, SetState, ShowToast from prefab ui.app import PrefabApp from prefab ui.components import H2, RESULT, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Carousel, Column, Else, Icon, If, Input, Row, Rx, Text, def field icon: str, value: str - None: with Row gap=2, css class="items-center text-sm text-slate-700" : Icon icon, size="sm" Text value def card contact: dict, on save: Callable - None: contact id = contact "id" with Card css class="w-full text-left" : with CardHeader : CardTitle contact "name" CardDescription f"{contact 'job title' } · {contact 'company' }" with CardContent : with If f" editing {contact id}" : with Column gap=2 : field "mail", f"{Rx f'email {contact id}' }" field "phone", f"{Rx f'phone {contact id}' }" Button "Edit", size="sm", variant="outline", on click= SetState f"draft email {contact id}", Rx f"email {contact id}" , SetState f"draft phone {contact id}", Rx f"phone {contact id}" , SetState f"editing {contact id}", True , , with Else : with Column gap=2 : Input name=f"draft email {contact id}", value=contact "email" Input name=f"draft phone {contact id}", value=contact "phone" with Row gap=2 : Button "Save", size="sm", on click=CallTool on save, arguments={ "contact id": contact id, "email": Rx f"draft email {contact id}" , "phone": Rx f"draft phone {contact id}" , }, on success= SetState f"email {contact id}", Rx f"draft email {contact id}" , , SetState f"phone {contact id}", Rx f"draft phone {contact id}" , , SetState f"editing {contact id}", False , ShowToast f"{RESULT}", variant="success" , , on error=ShowToast "Could not save.", variant="error" , , Button "Cancel", size="sm", variant="outline", on click=SetState f"editing {contact id}", False , def editable carousel query: str, results: list dict , on save: Callable - PrefabApp: state: dict str, object = {} for contact in results: state |= { f"editing {contact 'id' }": False, f"email {contact 'id' }": contact "email" , f"draft email {contact 'id' }": contact "email" , f"phone {contact 'id' }": contact "phone" , f"draft phone {contact 'id' }": contact "phone" , } with PrefabApp state=state as app: with Column gap=3, css class="w-full p-3 text-left" : H2 f"{len results } contacts for “{query}”", css class="text-base font-bold", with Carousel visible=1.2, gap=12, loop=False, show dots=True : for contact in results: card contact, on save return app