← Blog
AI & Agents·05 September 2026·17 min read

UI components in chat: when the agent sends a table you can actually use

Agents answer with text — and every reaction to it starts with switching applications. MCP Apps change that: a tool delivers the data, a ui:// resource delivers the interface, and you click right inside the conversation. With a working example server, real payloads, the Power Apps route — and the honest status: available in VS Code and Microsoft 365 Copilot, preview in Power Apps, only announced for Copilot Studio.

The flow Rechnung freigeben (“approve invoice”) runs hourly, and this morning one of the runs failed. You ask your agent about the last runs and get a clean list — and still have to open the portal, find the flow, find the run and restart it. The agent has not taken the work off your hands, it has moved it.

That the agent knows about those runs at all comes down to the Model Context Protocol (MCP): an open socket through which an agent reaches other systems. An MCP server offers tools, the agent calls them and gets data back. What comes back has always been text or JSON, which the model then turns into an answer.

MCP Apps extend exactly that way back: a tool may ship the interface along with its data. What lands in the chat is not a list but a table with a button — and the click goes straight back to the server, without a detour through the model. In the Microsoft world this is currently seeping in: in VS Code and Microsoft 365 Copilot it has arrived, in Power Apps it is public preview, in Copilot Studio it is not there yet.

Four things are in this article:

  1. How it works — the two MCP building blocks behind such a component.
  2. Two working examples — a small MCP server you start yourself in a few minutes: first a table of flow runs, then an order flow over three steps. At the end the same server runs inside the real Copilot Chat in VS Code.
  3. The Power Apps route — there the platform generates the MCP server for you instead of you running one.
  4. What is missing — the gaps Microsoft documents openly.

What you see first

The usual setup: an MCP server hangs off the agent, a tool returns clean JSON, the model turns that into an answer. What ends up in the chat looks like this:

2026-09-05T06:12:04Z | Rechnung freigeben | Failed | BadRequest: 'approverEmail' is required
2026-09-05T05:12:03Z | Rechnung freigeben | Succeeded
2026-09-05T04:12:01Z | Rechnung freigeben | Running

Correct, complete — and dead. None of it is clickable. The agent can tell you that a run failed; it cannot offer a button to restart it.

What actually happens technically

A UI component consists of two MCP primitives tied together:

  1. A resource with the ui://… scheme. It returns a self-contained HTML file — the component.
  2. A tool whose _meta.ui.resourceUri field points at exactly that resource.

When the host calls the tool, it fetches the HTML resource, renders it in a sandboxed iframe and pushes the tool result into it. Inside the component the counterpart runs: the App class from @modelcontextprotocol/ext-apps. It registers with the host through app.connect(), receives the data through app.ontoolresult — and can call another tool on the server itself through app.callServerTool().

That is the actual point: communication runs in both directions. The user clicks in the component, the component calls the server, the server answers, the component redraws — without anyone leaving the chat, and without the model having to guess again in between.

Architecture: at the top the host (VS Code, Microsoft 365 Copilot) with the chat and its model plus the sandboxed iframe the component runs in; below it the MCP server with its tools and the ui:// resource; at the bottom the real system of record. Arrows show tools/call and resources/read going down, content and structuredContent coming back up, and app.callServerTool as its own path bypassing the model

Three responsibilities worth keeping apart: the real API behind it all knows the data. The MCP server is the translator in front of it — it offers the tools and ships the interface as a resource along with them. And the engine belongs to the host: the component runs in its sandboxed iframe, technically just HTML with JavaScript talking to the host over postMessage.

The split in the tool result matters:

  • content — the text fallback. It stays mandatory, because not every surface renders components.
  • structuredContent — the data for the component. It arrives in the widget as result.structuredContent, not as result.data.

The obvious shortcut that isn’t one

Two reflexes lead astray here.

“That’s what Adaptive Cards are for.” Adaptive Cards are the older, proven route for structured answers in Copilot Studio, and for forms and follow-up questions they remain the right choice. But the maker defines the card up front, and its behaviour ends with the actions it declares. An MCP component is a real web application inside the chat that decides at runtime what it loads next.

“Then I’ll click it together in Copilot Studio.” That does not work today. Copilot Studio can connect MCP servers and call their tools — the inline rendered widget is not part of that yet. The Microsoft 365 roadmap entry 570433 (“Deliver rich interactive app experiences in Copilot Studio agents”) lists preview for October 2026 and general availability for November 2026. Anyone who wants to try something today does it elsewhere:

SurfaceStatus
VS Code, GitHub Copilot Chatavailable; MCP Apps since January 2026, setting chat.mcp.apps.enabled (default: on, marked experimental)
Microsoft 365 Copilot Chat (declarative agents)available; generally available since March 2026 according to the docs
Power Apps, model-driven app with App MCPpublic preview
Copilot Studio agentsannounced: preview 10/2026, GA 11/2026

Let’s build it

The fastest way to get a feel for it is a small MCP server you start yourself — not the Power Apps route. That one needs a Microsoft 365 Copilot licence and a model-driven app (more on it further down), while the mechanism stays the same. Locally you see it in five minutes and without a tenant.

Three parts play together:

  • The MCP server — the piece you write. It offers the tool list_flow_runs and the component that goes with it (the second example further down lives in the same server). It stands in for whatever really knows about flow runs, the Power Automate management API for instance; the runs themselves are demo data inside the server, so you do not need a tenant.
  • The component — a single HTML file: the table that appears in the chat, with a button Erneut ausführen (“run again”) on failed runs.
  • The host — the place that brings both together and displays the component. In production that is VS Code or Microsoft 365 Copilot; to try it out we start with the reference host from the MCP repository.

Setting up the project

mkdir flow-runs-mcp-app && cd flow-runs-mcp-app
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx

I used @modelcontextprotocol/ext-apps 1.7.5 and @modelcontextprotocol/sdk 1.30.0 on Node 25. vite-plugin-singlefile bundles the component together with its script and CSS into one HTML file — that saves you the work on the Content Security Policy of the iframe the component later runs in.

The server

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import path from "node:path";

const server = new McpServer({ name: "Flow Runs", version: "1.0.0" });

// The ui:// scheme marks the resource as a UI component.
const resourceUri = "ui://flow-runs/mcp-app.html";

type Run = { id: string; flow: string; status: string; started: string; error?: string };
const runs: Run[] = [
  { id: "08584...231", flow: "Rechnung freigeben", status: "Failed",    started: "2026-09-05T06:12:04Z", error: "BadRequest: 'approverEmail' is required" },
  { id: "08584...230", flow: "Rechnung freigeben", status: "Succeeded", started: "2026-09-05T05:12:03Z" },
  { id: "08584...229", flow: "Rechnung freigeben", status: "Running",   started: "2026-09-05T04:12:01Z" },
];

registerAppTool(
  server,
  "list_flow_runs",
  {
    title: "Flow-Läufe anzeigen",
    description: "Zeigt die letzten Läufe eines Cloud Flow als interaktive Tabelle.",
    inputSchema: {},
    annotations: { readOnlyHint: true },
    _meta: { ui: { resourceUri } },
  },
  async () => ({
    structuredContent: { runs },
    content: [{
      type: "text",
      text: runs.map((r) => `${r.started} | ${r.flow} | ${r.status}`).join("\n"),
    }],
  }),
);

registerAppTool(
  server,
  "resubmit_flow_run",
  {
    title: "Lauf erneut ausführen",
    description: "Startet einen fehlgeschlagenen Lauf neu.",
    inputSchema: { runId: z.string() },
    _meta: { ui: { resourceUri } },
  },
  async ({ runId }) => {
    const run = runs.find((r) => r.id === runId);
    if (run) { run.status = "Running"; delete run.error; }
    return { structuredContent: { runs }, content: [{ type: "text", text: `Lauf ${runId} neu gestartet.` }] };
  },
);

registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: await fs.readFile(path.join(import.meta.dirname, "dist", "mcp-app.html"), "utf-8"),
    }],
  }),
);

const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => console.log("MCP server on http://localhost:3001/mcp"));

The component

The interesting part is the handful of lines that attach the component to the host — register the handlers first, then connect():

// src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Flow Runs UI", version: "1.0.0" });

function render(runs: Run[]) {
  // … draw the table …
  // Button only on failed runs:
  b.onclick = async () => {
    const res = await app.callServerTool({
      name: "resubmit_flow_run",
      arguments: { runId: r.id },
    });
    render((res.structuredContent as { runs: Run[] }).runs);
    app.sendSizeChanged({ height: document.body.scrollHeight });
  };
}

app.ontoolresult = (result) =>
  render((result.structuredContent as { runs: Run[] })?.runs ?? []);
app.connect();

app.sendSizeChanged() is not a detail for later: the host does not know how tall the content is. Without that call the component sits in a frame that is too small, with a scrollbar.

Build it, start it, look inside

npx vite build     # → dist/mcp-app.html
npx tsx server.ts  # → http://localhost:3001/mcp

Whether the wiring is correct shows in tools/list alone, without any host:

curl -s -X POST http://localhost:3001/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
{"name":"list_flow_runs","title":"Flow-Läufe anzeigen",
 "annotations":{"readOnlyHint":true},
 "_meta":{"ui":{"resourceUri":"ui://flow-runs/mcp-app.html"},
          "ui/resourceUri":"ui://flow-runs/mcp-app.html"}}

Two things are worth noting here. First, the SDK writes the association out twice — nested as ui.resourceUri and flat as ui/resourceUri, so that older hosts find it too. Second, the component shows up in resources/list with a MIME type of its own:

{"uri":"ui://flow-runs/mcp-app.html",
 "mimeType":"text/html;profile=mcp-app"}

The tool call then returns both channels side by side — fallback text and data:

{"content":[{"type":"text","text":"2026-09-05T06:12:04Z | Rechnung freigeben | Failed | …"}],
 "structuredContent":{"runs":[
   {"id":"08584...231","flow":"Rechnung freigeben","status":"Failed",
    "started":"2026-09-05T06:12:04Z","error":"BadRequest: 'approverEmail' is required"}]}}

Looking at it in a host

The reference host from the ext-apps repository is enough to see it. It is not a copy of Copilot, but it speaks the same protocol:

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install && npm run build
cd examples/basic-host && npm install
SERVERS='["http://localhost:3001/mcp"]' npm start   # → http://localhost:8080

The reference host shows the tool input, the rendered component and the raw tool result underneath each other

The input at the top, the raw result at the bottom — and between them the component, which turns that same result into a table with status markers and a button. A click on Erneut ausführen calls resubmit_flow_run on the server, and the table redraws with the new status:

The component inside the host: a click on the button sets the failed run to Running, the error message and the button disappear — tool input and tool result frame the call

There is no model call between the click and the redrawn table. The click goes straight to the server.

A second flow: creating something, not just showing it

The table shows the principle, but it stays a single click. It gets more interesting when several steps hang together and something exists at the end. The second tool in the example server, order_office_supplies, opens an order for office supplies: pick quantities, assign a cost centre, review, order for real — and then ask for the status.

The order flow in three steps: selection with quantities and cost centre, review, then the confirmation with order number, amount and approval trail

Three steps, two calls back to the server: place_order creates the order and returns order number, amount and approval trail, order_status fetches the current state. Both run from inside the component, with no model in between.

Three things stand out.

The component keeps its own state. Selection, quantities and cost centre live in the widget — not in the server and not in the model. Only when you order does all of it go to the tool in one go, against a schema the server validates:

inputSchema: {
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
  costCenter: z.string(),
},

You secure writing actions yourself. place_order creates something, and the host does not help: destructiveHint and idempotentHint are ignored, and there is a confirmation before sending only if the component asks for one. Hence the intermediate review step with its own order for real button, rather than ordering straight from the list.

Pin the height. The three steps have different heights. Without a minimum height in the component, the conversation jumps on every step — whatever sits below it slides up and down. A min-height on the body costs a little whitespace and keeps the flow calm.

The same thing in a real Copilot Chat

The reference host shows the protocol, but not everyday use. For that there is VS Code: since January 2026 its Copilot Chat renders MCP Apps inline in the conversation — according to the VS Code team, as the first major editor. The setting is called chat.mcp.apps.enabled, defaults to true and is marked experimental; so there is nothing to switch on.

To get that same demo server in there, one file in the project is enough:

// .vscode/mcp.json
{
  "servers": {
    "flow-runs-demo": { "type": "http", "url": "http://localhost:3001/mcp" }
  }
}

After that the tool is available in the chat — and the answer to “Zeig mir die letzten Flow-Läufe” (“show me the last flow runs”) is not a list:

Copilot Chat in VS Code: asked for the last flow runs, it shows the line "Ran Flow-Läufe anzeigen – flow-runs-demo (MCP Server)" and below it the rendered component with status markers and the button "Erneut ausführen"

The line above names the tool and the server, and below it sits the component — in the place where prose would otherwise be. The button behaves exactly as it does in the reference host: the click goes straight to the server, the model is not asked again.

The same click in VS Code's Copilot Chat: the failed run flips to Running, the error message and the button disappear, the table gets shorter

For corporate tenants there is a caveat attached. With Copilot Business and Copilot Enterprise the organization policy “MCP servers in Copilot” has to be enabled — it is off by default. Since August 2026 allowlists in enterprise managed settings come on top (allowedMcpServers, deniedMcpServers), enforced in VS Code among other clients. With Copilot Free, Pro, Pro+ and Max neither applies.

And in the Power Platform?

For model-driven apps there is a route that does not require you to run an MCP server yourself — Power Apps generates it. The sequence according to the docs (public preview, make.preview.powerapps.com):

  1. Open the app, choose App MCP on the left, then Set up MCP — once.
  2. Save and publish the app.
  3. Under Tools → Create custom tool, enter a name, a description and the instruction for the data output. The output type has to be JSON, otherwise there is nothing a component could render.
  4. Run Test, then paste the component’s HTML in the next step and save.
  5. Download app package and deploy the package in Teams or through the Microsoft 365 admin center.

The prerequisites are a Microsoft 365 Copilot licence for makers and end users, plus permission to upload custom apps in Teams. If creating the tool leaves you with the message “This feature has been disabled”, the environment setting for AI prompts is missing.

You do not have to write that HTML by hand. Microsoft ships a skill for coding agents that generates a component from a tool’s real JSON output:

/plugin marketplace add microsoft/power-platform-skills
/plugin install mcp-apps@power-platform-skills

You invoke it with /generate-mcp-app-ui, along with a description of the picture you want and the real tool output — the docs are explicit here: with sample data you get a component that breaks against the real tool. Give it the tool name as well and the skill wires up app.callServerTool for you. How to share skills like this across a team is covered in Share skills and agents across your team.

A checklist before your first attempt

  • Output type JSON. No structured data, no component. In your own server: fill structuredContent, not just content.
  • Always ship the text fallback. Surfaces without rendering otherwise show an empty answer.
  • connect() last. Register every handler before connecting, or the first result is lost.
  • The data lives in result.structuredContent — not in result.data.
  • On Copilot Business/Enterprise, check the policy. “MCP servers in Copilot” is off by default, and since August 2026 the allowlists allowedMcpServers and deniedMcpServers in enterprise managed settings come on top.
  • Allow the widget host in CORS. Copilot renders under {hashed-mcp-domain}.widget-renderer.usercontent.microsoft.com; the Widget Host URL Generator gives you the concrete value.
  • Register the redirect URI: https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect for Copilot, https://vscode.dev/redirect for the Agents Toolkit.
  • Authentication “None” is a development setting. OAuth 2.1 or Entra SSO before you roll out. What an agent identity does for you here — and what it does not — is in Entra Agent ID.
  • Check capabilities instead of assuming them. Unsupported calls are simply undefined, so if (!window.openai.requestDisplayMode) return null; respectively app.getHostContext()?.availableDisplayModes.
  • Design tokens instead of hex values (var(--colorNeutralForeground1)), or the component is unreadable in dark mode.

What stays uncomfortable

Availability is fragmented. The same feature is available in VS Code and Microsoft 365 Copilot, in preview in Power Apps and announced for Copilot Studio. Planning for Copilot Studio today means planning against a roadmap promise.

Not everything in the protocol arrives. Microsoft documents the gaps openly: file upload and download links from inside the component (uploadFile, getFileDownloadUrl), modal dialogs (requestModal) and frame_domains in the Content Security Policy are not supported. destructiveHint and idempotentHint on tools are ignored — securing a writing action is your job, not the host’s. Of the display modes only full screen comes through.

The component is code somebody has to maintain. My bundled example file is 432 kB, and Microsoft’s template loads Fluent UI from a CDN at runtime — without an internet connection the component stays empty. Not an argument against it, but an item in the maintenance budget that an Adaptive Card does not have.

The model stays outside. If the component loads data by itself, the model knows nothing about it at first. That is intentional and fast — but the next question in the chat may refer to a state the agent has never seen. For actions that affect the conversation, plan the way back into the context.

See also

Sources