Files
awesome-copilot/skills/dotnet-mcp-builder/references/mcp-apps.md
T
Adrien Clerbois aa01464ccb Update dotnet-mcp-builder skill to ModelContextProtocol 2.x (#2487)
* Update dotnet-mcp-builder skill to ModelContextProtocol 2.x

Align the skill with the C# SDK 2.0.0 release and the MCP 2026-07-28
spec: stable line is now 2.x, HttpServerTransportOptions.Stateless
defaults to true, roots/sampling/MCP-channel logging are [Obsolete]
(MCP9005) with the multi-round-trip input_required pattern as the
replacement, discovery-first negotiation (server/discover) supersedes
the initialize handshake, Mcp-Method/Mcp-Name routable headers, raw
structuredContent for non-object results, required Tool.inputSchema,
and the new ModelContextProtocol.Extensions.Tasks and
ModelContextProtocol.Extensions.Apps packages (typed MCP Apps support
replacing the hand-rolled _meta/ui:// pattern on 1.x).

* Address Copilot review: Apps extension accuracy, header scope, capability ownership

- packages.md: the Apps package replaces the manual _meta wiring, not the
  ui:// resource; note the experimental MCPEXP003 diagnostic; label the
  1.x -> 2.0 list as highlights and add the OAuth/SSE runtime changes
  with a pointer to the full release notes.
- transport-http.md: Mcp-Method is on every POST, Mcp-Name only on named
  invocations (tools/call, prompts/get, resources/read) - do not require
  it globally at gateways.
- mcp-apps.md: current MIME type is text/html;profile=mcp-app (skybridge
  is a legacy draft value); document [McpAppUi] + WithMcpApps().
- server-features.md: roots/sampling are client capabilities, only
  logging sits on ServerCapabilities.

* Correct stateful HTTP guidance: 2026-07-28 has no HTTP sessions

Per the official SDK v2 elicitation docs, a server with Stateless=false
refuses the 2026-07-28 revision so dual-path clients fall back to an
initialize-capable revision; ElicitAsync cannot be used on 2026-07-28
Streamable HTTP at all. Reframe stateful HTTP as down-level
compatibility mode and document the multi-round-trip pattern
(InputRequiredException / InputRequest.ForElicitation, retry with
InputResponses -> ElicitResult) as the current-protocol way to ask
mid-tool, across SKILL.md, transport-http.md, and elicitation.md.
2026-07-31 10:31:14 +10:00

9.8 KiB

MCP Apps (interactive UI)

MCP Apps is the official extension that lets a tool return an interactive UI rendered in a sandboxed iframe inside the host (Claude, Claude Desktop, VS Code Copilot, Goose, Postman, MCPJam). Typical use cases: charts, dashboards, multi-step forms, 3D viewers, real-time monitors, PDF/video viewers.

Important: SDK 2.x ships a dedicated extension package, ModelContextProtocol.Extensions.Apps, with typed MCP Apps support: register with .WithMcpApps() and annotate tools with [McpAppUi(ResourceUri = "ui://...")]. It replaces the hand-rolled _meta wiring, not the ui:// resource — you still register and serve the UI resource. The APIs are marked experimental (suppress diagnostic MCPEXP003); check the package page and SDK API reference for the current surface rather than guessing beyond those names. The manual pattern below is what you need on 1.x, which has no typed layer (was tracked in csharp-sdk#1431): serve a ui:// resource and emit the right _meta on the tool.

How it works (short version)

  1. You register a resource at a ui:// URI returning an HTML bundle.
  2. You register a tool whose definition includes _meta.ui.resourceUri pointing to that URI.
  3. When the LLM calls the tool, the host fetches the UI resource and renders it in a sandboxed iframe in the chat.
  4. The HTML talks to the host over postMessage JSON-RPC (use @modelcontextprotocol/ext-apps from the bundle, or hand-roll it).
  5. The app can call back into your MCP server (any tool), update the model context, etc.

The full protocol spec is at @modelcontextprotocol/ext-apps.

Step 1: Serve the UI resource

Bundle your HTML/JS/CSS into a single string (or load from wwwroot). Serve it at a ui:// URI.

using System.ComponentModel;
using System.IO;
using System.Reflection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

[McpServerResourceType]
public static class ChartUiResource
{
    [McpServerResource(
        UriTemplate = "ui://charts/interactive",
        Name = "Interactive chart",
        MimeType = "text/html;profile=mcp-app")]   // see "MIME type" note below
    [Description("UI bundle for the interactive chart MCP App.")]
    public static TextResourceContents GetUi()
    {
        // Load a bundled HTML/JS file from embedded resources or wwwroot.
        var html = LoadEmbeddedString("MyMcpServer.AppUi.chart.html");

        return new TextResourceContents
        {
            Uri = "ui://charts/interactive",
            MimeType = "text/html;profile=mcp-app",
            Text = html
        };
    }

    private static string LoadEmbeddedString(string resourceName)
    {
        var asm = Assembly.GetExecutingAssembly();
        using var stream = asm.GetManifestResourceStream(resourceName)
            ?? throw new InvalidOperationException($"Missing embedded resource {resourceName}");
        using var reader = new StreamReader(stream);
        return reader.ReadToEnd();
    }
}

MIME type note: the current Apps spec (2026-01-26) uses text/html;profile=mcp-app for app HTML so hosts can distinguish UI bundles from regular text/html previews. Earlier drafts used text/html+skybridge — treat that as legacy; some older hosts may still expect it.

Step 2: Emit _meta on the tool

The C# SDK's [McpServerTool] doesn't expose _meta in the attribute today, so set it via the lower-level Tool definition. Do this once at startup:

using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
using System.Text.Json.Nodes;

builder.Services.Configure<McpServerOptions>(options =>
{
    options.Capabilities ??= new();
    options.Capabilities.Tools ??= new();

    // Define the tool manually so we can attach _meta.
    var visualizeTool = new Tool
    {
        Name = "visualize_data",
        Description = "Visualize the user's data as an interactive chart.",
        InputSchema = JsonDocument.Parse("""
            {
              "type": "object",
              "properties": {
                "datasetId": { "type": "string", "description": "Dataset to visualize." }
              },
              "required": ["datasetId"]
            }
            """).RootElement,
        Meta = new JsonObject
        {
            ["ui"] = new JsonObject
            {
                ["resourceUri"] = "ui://charts/interactive"
                // Optionally:
                // ["csp"] = new JsonObject { ["default-src"] = "'self' https://cdn.example.com" },
                // ["permissions"] = new JsonArray("clipboard-write")
            }
        }
    };

    // Implement the call handler that returns the data the UI will render.
    options.Capabilities.Tools.ToolCollection ??= new();
    options.Capabilities.Tools.ToolCollection.Add(McpServerTool.Create(
        async (CallToolRequestParams req, CancellationToken ct) =>
        {
            var args = req.Arguments ?? new();
            var datasetId = args["datasetId"]!.GetValue<string>();
            var data = await LoadDataset(datasetId, ct);
            return new CallToolResult
            {
                Content = [new TextContentBlock { Text = JsonSerializer.Serialize(data) }],
                StructuredContent = JsonSerializer.SerializeToNode(data)
            };
        },
        visualizeTool));
});

If you don't need full structured content, the tool can return just JSON in a text block — the UI fetches it via app.callServerTool(...) after rendering.

Backwards compatibility key

Some older hosts expect _meta["ui/resourceUri"] instead of _meta.ui.resourceUri. Set both for safety:

Meta = new JsonObject
{
    ["ui"] = new JsonObject { ["resourceUri"] = "ui://charts/interactive" },
    ["ui/resourceUri"] = "ui://charts/interactive"   // legacy
}

Step 3: The HTML bundle

A minimum viable bundle: vanilla JS using @modelcontextprotocol/ext-apps. The simplest build is a single self-contained HTML file.

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Chart</title>
    <style>body { font-family: system-ui; margin: 0; }</style>
  </head>
  <body>
    <div id="root">Loading…</div>
    <script type="module">
      import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1";

      const app = new App();
      await app.connect();

      // Fetch the data we need from the server.
      const resp = await app.callServerTool({
        name: "visualize_data",
        arguments: { datasetId: "default" }
      });

      const data = JSON.parse(resp.content[0].text);
      document.getElementById("root").textContent =
        `Loaded ${data.points.length} data points.`;

      // Tell the model what just happened (becomes part of its context).
      await app.updateModelContext({
        content: [{ type: "text", text: "User opened the chart UI." }]
      });
    </script>
  </body>
</html>

Tip: for non-trivial UIs, build with Vite (React/Vue/Svelte/Solid — any of the official starter templates) and have the build emit a single inlined HTML you embed as a project resource.

Project layout

A pragmatic layout for an MCP App in .NET:

MyMcpServer/
├── Program.cs
├── Tools/
│   └── VisualizeDataTool.cs       # (or registered via Configure as above)
├── Resources/
│   └── ChartUiResource.cs         # serves the ui:// resource
├── AppUi/
│   ├── chart.html                 # bundled UI (Embedded Resource)
│   └── package.json + src/...     # if you build with Vite, output to chart.html
└── MyMcpServer.csproj

In the csproj:

<ItemGroup>
  <EmbeddedResource Include="AppUi\chart.html" />
</ItemGroup>

Read it via Assembly.GetManifestResourceStream("MyMcpServer.AppUi.chart.html").

Testing locally

  1. Run your MCP server (STDIO or HTTP).
  2. Use a host that supports MCP Apps — Claude Desktop or VS Code Copilot Chat are the easiest.
  3. Trigger the tool via the LLM. The UI renders inline.

For pure-UI iteration, MCP Inspector shows resource contents but does not fully render apps; for that, point Claude Desktop at your dev server.

Pitfalls

  • Wrong MIME type. Use text/html;profile=mcp-app (current spec; text/html+skybridge is a legacy draft value). Plain text/html may still work on lenient hosts but isn't future-proof.
  • CSP too tight or too loose. If your UI loads from a CDN, declare it in Meta["ui"]["csp"] on the Tool definition (this serialises to _meta.ui.csp on the wire). Otherwise the iframe sandbox blocks it.
  • Forgetting Tool.Meta on the tool. Without the Meta property containing the ui.resourceUri entry, the host treats your tool as a regular text-returning tool. The UI never appears.
  • Trying to use browser APIs outside the sandbox. No cookies, no localStorage from the parent. Use app.updateModelContext and tool calls for state.

Migrating from the manual pattern

On 2.x, ModelContextProtocol.Extensions.Apps replaces the manual Configure block: .WithMcpApps() plus [McpAppUi(ResourceUri = "ui://...")] on the tool, with the extension handling the Apps capability negotiation. You still serve the ui:// resource (with the current text/html;profile=mcp-app MIME type) and keep your HTML bundle — keep the UI HTML as embedded resources so the migration is mechanical. The APIs are experimental (MCPEXP003); consult the package docs for anything beyond this surface rather than inventing it.