mcp-apps-sdk

v2026.09.25

This skill should be used when writing code against @modelcontextprotocol/ext-apps — registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, the App class and its handlers, the React useApp hook, the app-bridge entry point, and the Vite single-file build that MCP Apps expect.

GitHub
Install command
npx skhub add thelobbi/mcp-apps-sdk
Markdown
SKILL.md

@modelcontextprotocol/ext-apps — the SDK

One package, four entry points:

ImportSideContains
@modelcontextprotocol/ext-appsViewApp class
@modelcontextprotocol/ext-apps/serverServerregisterAppTool, registerAppResource, RESOURCE_MIME_TYPE
@modelcontextprotocol/ext-apps/reactViewuseApp and React bindings
@modelcontextprotocol/ext-apps/app-bridgeViewlow-level bridge for non-JS frameworks
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
npm install -D typescript vite vite-plugin-singlefile express cors tsx \
               @types/express @types/cors

Always npm install by name. Do not hand-pin versions in package.json — the View SDK and server SDK must move together.

Server side

// 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 express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import path from "node:path";

const server = new McpServer({ name: "Weather", version: "1.0.0" });
const resourceUri = "ui://weather/forecast.html";

registerAppTool(
  server,
  "render_forecast",
  {
    title: "Render forecast",
    description: "Render the 7-day forecast widget. Call get_forecast first.",
    inputSchema: { city: z.string(), days: z.array(DayShape) },
    _meta: { ui: { resourceUri } },
  },
  async ({ city, days }) => ({
    // structuredContent is what the View renders …
    structuredContent: { city, days },
    // … content is what a text-only host and the model read.
    content: [{ type: "text", text: `7-day forecast for ${city}.` }],
  }),
);

registerAppResource(
  server,
  "forecast-ui",            // human-readable name
  resourceUri,              // the ui:// URI
  { 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",
        ),
        // CSP belongs HERE, in the contents _meta — not in the config arg above.
        _meta: {
          ui: {
            prefersBorder: true,
            csp: {
              connectDomains: ["https://api.weather.example"],
              resourceDomains: ["https://static.weather.example"],
            },
          },
        },
      },
    ],
  }),
);

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("http://localhost:3001/mcp"));

registerAppTool/registerAppResource are thin wrappers over the base SDK's registerTool/registerResource that enforce the MCP Apps shape. Using the base functions directly works too — you just have to spell _meta and the mimeType yourself.

View side — vanilla

import { App } from "@modelcontextprotocol/ext-apps";

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

// ⚠ Every handler BEFORE connect().
app.ontoolinput   = (params) => renderSkeleton(params);
app.ontoolresult  = (result) => render(result.structuredContent);
app.onteardown    = () => flush();
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);

app.connect();

App surface

MemberKindNotes
connect()methodPerforms ui/initialize. Call once, last.
callServerTool({ name, arguments })methodFull round-trip to the server. Returns CallToolResult.
readResource({ uri })methodRead another server resource.
sendMessage({ … })methodInject a follow-up message into the conversation.
updateModelContext(ctx)methodTell the model what the user is now looking at.
requestDisplayMode({ mode })method"inline" | "fullscreen" | "pip". Check availability first.
sendSizeChanged({ width, height })methodDrives host iframe resizing.
openLink({ url })methodHost-mediated navigation.
sendLog({ level, data })methodLogs to the host — survives when devtools do not.
getHostContext()methodTheme, display mode, viewport, locale, safe area.
getHostCapabilities()methodWhat this host actually implements.
getHostVersion()methodHost name/version.
ontoolinputhandlerArguments, before the result.
ontoolinputpartialhandlerStreaming healed-JSON args. May be incomplete.
ontoolresulthandlerThe CallToolResult.
ontoolcancelledhandlerAbort in-flight rendering.
onteardownhandlerLast chance to persist.
onhostcontextchangedhandlerTheme/mode/viewport change.

Not every host implements every member — see host-capability-matrix. Guard with getHostCapabilities() or a plain truthiness check.

View side — React

import { useApp } from "@modelcontextprotocol/ext-apps/react";

export function Forecast() {
  const { app, toolInput, toolResult, hostContext } = useApp({
    name: "Forecast",
    version: "1.0.0",
  });

  if (!toolResult) return <Skeleton city={toolInput?.city} />;

  const { city, days } = toolResult.structuredContent as ForecastData;

  return (
    <div data-theme={hostContext?.theme}>
      <h1>{city}</h1>
      {days.map((d) => <Day key={d.date} {...d} />)}
      <button onClick={() => app.callServerTool({
        name: "get_forecast", arguments: { city, refresh: true },
      })}>Refresh</button>
    </div>
  );
}

useApp handles connect/teardown for you. Do not also call app.connect().

Vue, Svelte, Preact, and Solid work — they just use the App class or app-bridge directly and manage the lifecycle themselves.

Build: single-file is the path of least resistance

The View is served as one HTML string. Either bundle everything inline, or declare every asset origin in csp.resourceDomains. Inline is simpler.

// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: { outDir: "dist", rollupOptions: { input: process.env.INPUT } },
});
{
  "type": "module",
  "scripts": {
    "build": "INPUT=mcp-app.html vite build",
    "serve": "npx tsx server.ts"
  }
}

"type": "module" is required. Run the TypeScript server with tsx, not node.

Testing

npm run build && npm run serve                  # your server on :3001

# In the ext-apps checkout:
cd examples/basic-host && npm install
SERVERS='["http://localhost:3001/mcp"]' npm start   # host on :8080

For a real host, tunnel and register a custom connector:

npx cloudflared tunnel --url http://localhost:3001

Then Claude → Settings → Connectors → Add custom connector. Custom connectors require a paid plan.

Debug with app.sendLog({ level: "info", data }) rather than console.log — the iframe's console is frequently not where you are looking.

Failure modes, ranked by frequency

  1. Handlers registered after connect() → the first tool-result is lost.
  2. CSP in the config arg instead of the contents _meta → all fetches blocked.
  3. Assets not inlined and not in resourceDomains → blank View.
  4. _meta.ui.resourceUri on the data tool → widget remounts on every fetch.
  5. Missing "type": "module" → import syntax errors at serve time.
  6. Same ui:// URI after a breaking change → stale Views in live conversations.

Related

  • mcp-apps-protocol — the wire format these functions produce.
  • ui-testing-harness — the local host loop in detail.
  • ui-performance — bundling, partial input, offscreen pausing.
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

MIT

Source path

plugins/mcp-apps-studio/skills/mcp-apps-sdk

Default branch

main

Latest commit

2f1269c

Tree SHA

629e050