Files
claude-plugins-official/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md
Tobin South f7ba55786d add(plugin): mcp-server-dev — skills for building MCP servers
Three skills guiding developers through MCP server design:
- build-mcp-server: entry-point decision guide (remote HTTP vs MCPB vs local)
- build-mcp-app: interactive UI widgets rendered in chat
- build-mcpb: bundled local servers with runtime

Includes reference files for scaffolds, tool design, auth (DCR/CIMD),
widget templates, manifest schema, and local security hardening.
2026-03-18 11:28:09 -07:00

8.2 KiB

name, description, version
name description version
build-mcp-app This skill should be used when the user wants to build an "MCP app", add "interactive UI" or "widgets" to an MCP server, "render components in chat", build "MCP UI resources", make a tool that shows a "form", "picker", "dashboard" or "confirmation dialog" inline in the conversation, or mentions "apps SDK" in the context of MCP. Use AFTER the build-mcp-server skill has settled the deployment model, or when the user already knows they want UI widgets. 0.1.0

Build an MCP App (Interactive UI Widgets)

An MCP app is a standard MCP server that also serves UI resources — interactive components rendered inline in the chat surface. Build once, runs in Claude and ChatGPT and any other host that implements the apps surface.

The UI layer is additive. Under the hood it's still tools, resources, and the same wire protocol. If you haven't built a plain MCP server before, the build-mcp-server skill covers the base layer. This skill adds widgets on top.


When a widget beats plain text

Don't add UI for its own sake — most tools are fine returning text or JSON. Add a widget when one of these is true:

Signal Widget type
Tool needs structured input Claude can't reliably infer Form
User must pick from a list Claude can't rank (files, contacts, records) Picker / table
Destructive or billable action needs explicit confirmation Confirm dialog
Output is spatial or visual (charts, maps, diffs, previews) Display widget
Long-running job the user wants to watch Progress / live status

If none apply, skip the widget. Text is faster to build and faster for the user.


Architecture: two deployment shapes

Remote MCP app (most common)

Hosted streamable-HTTP server. Widget templates are served as resources; tool results reference them. The host fetches the resource, renders it in an iframe sandbox, and brokers messages between the widget and Claude.

┌──────────┐  tools/call   ┌────────────┐
│  Claude  │─────────────> │ MCP server │
│   host   │<── result ────│  (remote)  │
│          │  + widget ref │            │
│          │               │            │
│          │ resources/read│            │
│          │─────────────> │  widget    │
│ ┌──────┐ │<── template ──│  HTML/JS   │
│ │iframe│ │               └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘

MCPB-packaged MCP app (local + UI)

Same widget mechanism, but the server runs locally inside an MCPB bundle. Use this when the widget needs to drive a local application — e.g., a file picker that browses the actual local disk, a dialog that controls a desktop app.

For MCPB packaging mechanics, defer to the build-mcpb skill. Everything below applies to both shapes.


How widgets attach to tools

A tool declares a widget by returning an embedded resource in its result alongside (or instead of) text content. The resource's mimeType tells the host to render it, and the text field carries the widget's HTML.

server.tool(
  "pick_contact",
  "Open an interactive contact picker. The user selects one contact; its ID is returned.",
  {
    filter: z.string().optional().describe("Optional name/email prefix filter"),
  },
  async ({ filter }) => {
    const contacts = await listContacts(filter);
    return {
      content: [
        {
          type: "resource",
          resource: {
            uri: "ui://widgets/contact-picker",
            mimeType: "text/html+skybridge",
            text: renderContactPicker(contacts),
          },
        },
      ],
    };
  },
);

The host renders the resource in a sandboxed iframe. The widget posts a message back when the user picks something; the host injects that result into the conversation so Claude can continue.


Widget runtime contract

Widgets run in a sandboxed iframe. They talk to the host via window.parent.postMessage with a small set of message types. The exact envelope is host-defined — the MCP apps SDK wraps it so you don't hand-roll postMessage.

What widgets can do:

  • Render arbitrary HTML/CSS/JS (sandboxed — no same-origin access to the host page)
  • Receive an initial data payload from the tool result
  • Post a result back (ends the interaction, value flows to Claude)
  • Post progress updates (for long-running widgets)
  • Request the host call another tool on the same server

What widgets cannot do:

  • Access the host page's DOM, cookies, or storage
  • Make network calls to origins other than your MCP server (CSP-restricted)
  • Persist state across renders (each tool call is a fresh iframe)

Keep widgets small and single-purpose. A picker picks. A form submits. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.


Scaffold: minimal form widget

Tool (TypeScript SDK):

import { renderWidget } from "./widgets";

server.tool(
  "create_ticket",
  "Open a form to create a support ticket. User fills in title, priority, and description.",
  {},
  async () => ({
    content: [
      {
        type: "resource",
        resource: {
          uri: "ui://widgets/create-ticket",
          mimeType: "text/html+skybridge",
          text: renderWidget("create-ticket", {
            priorities: ["low", "medium", "high", "urgent"],
          }),
        },
      },
    ],
  }),
);

Widget template (widgets/create-ticket.html):

<!doctype html>
<meta charset="utf-8" />
<style>
  body { font: 14px system-ui; margin: 12px; }
  label { display: block; margin-top: 8px; font-weight: 500; }
  input, select, textarea { width: 100%; padding: 6px; margin-top: 2px; }
  button { margin-top: 12px; padding: 8px 16px; }
</style>
<form id="f">
  <label>Title <input name="title" required /></label>
  <label>Priority
    <select name="priority">
      {{#each priorities}}<option>{{this}}</option>{{/each}}
    </select>
  </label>
  <label>Description <textarea name="description" rows="4"></textarea></label>
  <button type="submit">Create</button>
</form>
<script type="module">
  import { submit } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
  document.getElementById("f").addEventListener("submit", (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.target));
    submit(data);  // → flows back to Claude as the tool's result
  });
</script>

renderWidget is a ~10-line template function — see references/widget-templates.md.


Design notes that save you a rewrite

One widget per tool. Resist the urge to build one mega-widget that does everything. One tool → one focused widget → one clear result shape. Claude reasons about these far better.

Tool description must mention the widget. Claude only sees the tool description when deciding what to call. "Opens an interactive picker" in the description is what makes Claude reach for it instead of guessing an ID.

Widgets are optional at runtime. Hosts that don't support the apps surface fall back to showing the resource as a link or raw text. Your tool should still return something sensible in content[].text alongside the widget for that case.

Don't block on widget results for read-only tools. A widget that just displays data (chart, preview) shouldn't require a user action to complete. Return the display widget and a text summary in the same result so Claude can continue reasoning without waiting.


Testing

  • Local: point Claude desktop's MCP config at http://localhost:3000/mcp, trigger the tool, check the widget renders and submits.
  • Host fallback: disable the apps surface (or use a host without it) and confirm the tool degrades gracefully.
  • CSP: open browser devtools on the iframe — CSP violations are the #1 reason widgets silently fail.

Reference files

  • references/widget-templates.md — reusable HTML scaffolds for form / picker / confirm / progress
  • references/apps-sdk-messages.md — the postMessage protocol between widget and host