Control Chrome
wxtsky/byob
Control and inspect the user's real Google Chrome through byob's local MCP tools.
Guides building an MCP app: an MCP server that also serves interactive UI widgets such as forms, pickers and confirm dialogs, rendered inline in chat hosts like Claude and ChatGPT.
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install anthropics/claude-plugins-official build-mcp-app --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .claude/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .claude/skills/build-mcp-app && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .claude/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-appType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install anthropics/claude-plugins-official build-mcp-app --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .agents/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .agents/skills/build-mcp-app && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .agents/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install anthropics/claude-plugins-official build-mcp-app --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .cursor/skills/build-mcp-app && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .cursor/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/anthropics/claude-plugins-official.git --path plugins/mcp-server-dev/skills/build-mcp-app--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install anthropics/claude-plugins-official build-mcp-app --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .gemini/skills/build-mcp-app && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .gemini/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install anthropics/claude-plugins-official build-mcp-appInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .github/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .github/skills/build-mcp-app && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .github/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install anthropics/claude-plugins-official build-mcp-app --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/anthropics/claude-plugins-official.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/plugins/mcp-server-dev/skills/build-mcp-app .opencode/skills/build-mcp-app && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "build-mcp-app" agent skill from https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app into .opencode/skills/build-mcp-app/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "build-mcp-app", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
build-mcp-appGuides building an MCP app: an MCP server that also serves interactive UI widgets such as forms, pickers and confirm dialogs, rendered inline in chat hosts like Claude and ChatGPT.
An MCP app is a standard MCP server that additionally serves UI resources, so one build runs in Claude, ChatGPT and any other host that implements the apps surface. The widget layer sits on top of ordinary tools and resources, and the companion `build-mcp-server` skill covers the base layer. The skill lists the Claude-specific `_meta.ui` keys: `resourceUri` for the `ui://` resource a tool renders, `visibility` for widget-only helper tools, `prefersBorder`, and `csp` origin declarations that default to blocking everything.
It stresses that a widget has to earn its place. Add one when a tool needs structured input, the user must choose from a list, a destructive or billable action needs confirmation, the output is visual, or a long job should show progress; otherwise return text. It also asks you to check whether elicitation already covers the need. Reference files cover abuse protection, apps SDK messages, the iframe sandbox, payload budgeting, widget templates and a directory checklist.
2 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit b8e53f1. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
npmnpxFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
esm.shAlso links to:
claude.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
MCP App Builder loads about 4.7k tokens when it runs, and up to ~12k if it reads all its reference files. Until then it costs about 117 tokens; SKILL.md has 1,497 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from anthropics/claude-plugins-official at commit b8e53f1, republished under its Apache-2.0 licence (© anthropics). 1,497 words, ~4,743 tokens.
.claude/skills/build-mcp-app/SKILL.md (or your agent's skills folder). This skill also uses 6 other files; get the full folder from GitHub.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.
Testing in Claude: Add the server as a custom connector in claude.ai (via a Cloudflare tunnel for local dev) — this exercises the real iframe sandbox and
hostContext. See https://claude.com/docs/connectors/building/testing.
_meta.ui.* key | Where | Effect |
|---|---|---|
resourceUri | tool | Which ui:// resource the host renders for this tool's results. |
visibility: ["app"] | tool | Hide a widget-only helper tool (e.g. geometry/image fetcher called via callServerTool) from Claude's tool list. |
prefersBorder: false | resource | Drop the host's outer card border (mobile). |
csp.{connectDomains, resourceDomains, baseUriDomains} | resource | Declare external origins; default is block-all. frameDomains is currently restricted in Claude. |
hostContext.safeAreaInsets: {top, right, bottom, left} (px) — honor these for notches and the composer overlay.none) — static bearer is private-deploy only and blocks listing — plus tool annotations and 3–5 PNG screenshots; see references/directory-checklist.md.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.
Before building a widget, check if elicitation covers it. Elicitation is spec-native, zero UI code, works in any compliant host.
| Need | Elicitation | Widget |
|---|---|---|
| Confirm yes/no | ✅ | overkill |
| Pick from short enum | ✅ | overkill |
| Fill a flat form (name, email, date) | ✅ | overkill |
| Pick from a large/searchable list | ❌ (no scroll/search) | ✅ |
| Visual preview before choosing | ❌ | ✅ |
| Chart / map / diff view | ❌ | ✅ |
| Live-updating progress | ❌ | ✅ |
If elicitation covers it, use it. See ../build-mcp-server/references/elicitation.md.
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│ │
│ └──────┘ │
└──────────┘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.
A widget-enabled tool has two separate registrations:
_meta.ui.resourceUri. Its handler returns plain text/JSON — NOT the HTML.When Claude calls the tool, the host sees _meta.ui.resourceUri, fetches that resource, renders it in an iframe, and pipes the tool's return value into the iframe via the ontoolresult event.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// Plain JSON — the widget receives this via ontoolresult
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. The resource — serves the HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // your HTML string
}],
}),
);The URI scheme ui:// is convention. The mime type MUST be RESOURCE_MIME_TYPE ("text/html;profile=mcp-app") — this is how the host knows to render it as an interactive iframe, not just display the source.
App classInside the iframe, your script talks to the host via the App class from @modelcontextprotocol/ext-apps. This is a persistent bidirectional connection — the widget stays alive as long as the conversation is active, receiving new tool results and sending user actions.
<script type="module">
/* ext-apps bundle inlined at build time → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// Set handlers BEFORE connecting
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// Later, when the user clicks something:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>The /*__EXT_APPS_BUNDLE__*/ placeholder gets replaced by the server at startup with the contents of @modelcontextprotocol/ext-apps/app-with-deps — see references/iframe-sandbox.md for why this is necessary and the rewrite snippet. Do not import { App } from "https://esm.sh/..."; the iframe's CSP blocks the transitive dependency fetches and the widget renders blank.
| Method | Direction | Use for |
|---|---|---|
app.ontoolresult = fn | Host → widget | Receive the tool's return value |
app.ontoolinput = fn | Host → widget | Receive the tool's input args (what Claude passed) |
app.sendMessage({...}) | Widget → host | Inject a message into the conversation |
app.updateModelContext({...}) | Widget → host | Update context silently (no visible message) |
app.callServerTool({name, arguments}) | Widget → server | Call another tool on your server |
app.openLink({url}) | Widget → host | Open a URL in a new tab (sandbox blocks window.open) |
app.getHostContext() / app.onhostcontextchanged | Host → widget | Theme, host CSS vars, containerDimensions, displayMode, deviceCapabilities |
app.requestDisplayMode({mode}) | Widget → host | Ask for inline / pip / fullscreen |
app.downloadFile({name, mimeType, content}) | Widget → host | Host-mediated download (base64 content) |
new App(info, caps, {autoResize: true}) | — | Iframe height tracks rendered content |
sendMessage is the typical "user picked something, tell Claude" path. updateModelContext is for state that Claude should know about but shouldn't clutter the chat. openLink is required for any outbound navigation — window.open and <a target="_blank"> are blocked by the sandbox attribute.
What widgets cannot do:
callServerTool)app.openLink({url})data: URLs server-sideKeep widgets small and single-purpose. A picker picks. A chart displays. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.
Install:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod expressServer (src/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 { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// Inline the ext-apps browser bundle into the widget HTML.
// The iframe CSP blocks CDN script fetches — bundling is mandatory.
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker. User selects one contact.",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "Contact Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);For local-only widget apps (driving a desktop app, reading local files), swap the transport to StdioServerTransport and package via the build-mcpb skill.
Widget (widgets/picker.html):
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>See references/widget-templates.md for more widget shapes.
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 simply ignore _meta.ui and render the tool's text content normally. Since your tool handler already returns meaningful text/JSON (the widget's data), degradation is automatic — Claude sees the data directly instead of via the widget.
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.
Layout-fork by item count, not by tool count. If one use case is "show one result in detail" and another is "show many results side-by-side", don't make two tools — make one tool that accepts items[], and let the widget pick a layout: items.length === 1 → detail view, > 1 → carousel. Keeps the server schema simple and lets Claude decide count naturally.
Put Claude's reasoning in the payload. A short note field on each item (why Claude picked it) rendered as a callout on the card gives users the reasoning inline with the choice. Mention this field in the tool description so Claude populates it.
Normalize image shapes server-side. If your data source returns images with wildly varying aspect ratios, rewrite to a predictable variant (e.g. square-bounded) before fetching for the data-URL inline. Then give the widget's image container a fixed aspect-ratio + object-fit: contain so everything sits centered.
Follow host theme. app.getHostContext()?.theme (after connect()) plus app.onhostcontextchanged for live updates. Toggle a .dark class on <html>, keep colors in CSS custom props with a :root.dark {} override block, set color-scheme. Disable mix-blend-mode: multiply in dark — it makes images vanish.
Claude Desktop — current builds still require the command/args config shape (no native "type": "http"). Wrap with mcp-remote and force http-only transport so the SSE probe doesn't swallow widget-capability negotiation:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}Desktop caches UI resources aggressively. After editing widget HTML, fully quit (⌘Q / Alt+F4, not window-close) and relaunch to force a cold resource re-fetch.
Headless JSON-RPC loop — fast iteration without clicking through Desktop:
# test.jsonl — one JSON-RPC message per line
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"your_tool","arguments":{...}}}
(cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-httpThe sleep keeps stdin open long enough to collect all responses. Parse the jsonl output with jq or a Python one-liner.
Widget dev loop — avoid the ⌘Q-relaunch cycle entirely by serving the inlined widget HTML at a plain GET route with a fake ExtApps shim that fires ontoolresult from a query param:
app.get("/widget-preview", (_req, res) => {
const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{
constructor(){this.h={}} ontoolresult;onhostcontextchanged;
async connect(){const p=new URLSearchParams(location.search).get("payload");
if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});}
getHostContext(){return{theme:"light"}}
sendMessage(m){console.log("sendMessage",m)} updateModelContext(){}
callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){}
}};`;
res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim));
});Open http://localhost:3000/widget-preview?payload={"rows":[...]} in a normal browser tab and iterate with ordinary devtools.
Host fallback — use a host without the apps surface (or MCP Inspector) and confirm the tool's text content degrades gracefully.
CSP debugging — open the iframe's own devtools console. CSP violations are the #1 reason widgets silently fail (blank rectangle, no error in the main console). See references/iframe-sandbox.md.
references/iframe-sandbox.md — CSP/sandbox constraints, the bundle-inlining pattern, image handling, host themingreferences/widget-templates.md — reusable HTML scaffolds for picker / confirm / progress / displayreferences/apps-sdk-messages.md — the App class API: widget ↔ host ↔ server messaging, lifecycle & supersessionreferences/payload-budgeting.md — host tool-result size caps, prune-then-truncate, heavy assets via callServerToolreferences/abuse-protection.md — Anthropic egress CIDRs, tiered rate limiting, trust proxy, response cachingreferences/directory-checklist.md — pre-flight for connector-directory submission© anthropics, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 6 other files (references) in plugins/mcp-server-dev/skills/build-mcp-app of anthropics/claude-plugins-official.
Open the folder on GitHubat commit b8e53f1
MCP App Builder next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| MCP App Builder this skillanthropics/claude-plugins-official | 38k | — | ~4.7k | Automated safety check: Pass | Apache-2.0 | |
| Control Chromewxtsky/byob | 143 | — | ~1.5k | Automated safety check: Warn | MIT | |
| Codex with ChatGPT Planning LoopXiaoDuoYa/codex-with-chatgpt | 7.2k | — | ~11k | Automated safety check: Notes | MIT | |
| Cao MCP Appsawslabs/cli-agent-orchestrator | 1.4k | — | ~1.9k | Automated safety check: Pass | Apache-2.0 | |
| Chatgpt AppsHaohao-end/openagent | 791 | 1 repos | ~4.9k | Automated safety check: Pass | Apache-2.0 | |
| Chatgpt App Builderalpic-ai/skybridge | 2.2k | — | ~1k | Automated safety check: Pass | MIT |
wxtsky/byob
Control and inspect the user's real Google Chrome through byob's local MCP tools.
XiaoDuoYa/codex-with-chatgpt
Uses ChatGPT in the browser as the planning and review brain for a Codex session, with Codex keeping all execution and ChatGPT reading the workspace through a bridge.
awslabs/cli-agent-orchestrator
Enable, operate, and extend CAO's MCP Apps surface — the host-rendered fleet dashboard visible inside MCP App hosts (Claude Desktop, ChatGPT, VS Code Copilot, Goose, Postman).
Haohao-end/openagent
Build, scaffold, refactor, and troubleshoot ChatGPT Apps SDK applications that combine an MCP server and widget UI.
alpic-ai/skybridge
Guide developers through creating and updating ChatGPT plugins.
vostride/agent-qa
A skill your agent uses when creating, editing, validating, or running agent-qa tests, suites, or hooks.
anthropics/claude-plugins-official
Explains how to write Claude Code plugin hooks, both prompt-based checks and bash commands, for events such as PreToolUse, Stop and SessionStart.
anthropics/claude-plugins-official
Explains how to write agents for Claude Code plugins: the markdown file with YAML frontmatter, trigger descriptions, model and color settings, and system prompt design.
anthropics/claude-plugins-official
Shows how Claude Code plugins keep per-project settings and state in .claude/plugin-name.local.md files with YAML frontmatter and a markdown body.
anthropics/claude-plugins-official
Explains how to bundle Model Context Protocol servers in a Claude Code plugin, covering config files, stdio, SSE, HTTP and WebSocket server types, and authentication.
anthropics/claude-plugins-official
Explains how to write Claude Code slash commands: Markdown files with YAML frontmatter, arguments, file references, bash context and interactive prompts.
anthropics/claude-plugins-official
Explains the directory layout, plugin.json manifest and component organization of a Claude Code plugin, including auto-discovery and portable paths.
Works with
Categories
Guides building an MCP app: an MCP server that also serves interactive UI widgets such as forms, pickers and confirm dialogs, rendered inline in chat hosts like Claude and ChatGPT. An MCP app is a standard MCP server that additionally serves UI resources, so one build runs in Claude, ChatGPT and any other host that implements the apps surface. The widget layer sits on top of ordinary tools and resources, and the companion `build-mcp-server` skill covers the base layer.
MCP App Builder fits situations like: building an MCP server whose tools show a form, picker or dashboard in chat; adding a confirmation dialog before a destructive or billable tool action; deciding whether a widget or plain elicitation fits a tool; preparing an MCP app for directory submission.
Run `npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a claude-code`. Or copy the skill folder (plugins/mcp-server-dev/skills/build-mcp-app in anthropics/claude-plugins-official) into .claude/skills/build-mcp-app in your project. Claude Code loads it when a task matches its description.
Run `npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a codex`. Or copy the skill folder (plugins/mcp-server-dev/skills/build-mcp-app in anthropics/claude-plugins-official) into .agents/skills/build-mcp-app in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add anthropics/claude-plugins-official --skill build-mcp-app -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/build-mcp-app, .gemini/skills/build-mcp-app, .github/skills/build-mcp-app and .opencode/skills/build-mcp-app in your project.
Going by SKILL.md and its folder, MCP App Builder needs the command-line tools its instructions call (npm and npx). Our summary lists: An existing or planned MCP server; A custom connector in claude.ai to test, with a Cloudflare tunnel for local dev.
SKILL.md names 2 domains. In commands or code: esm.sh; the agent is likely to contact it when it follows the instructions. As links in the text: claude.com. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
MCP App Builder is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 4.7k tokens (SKILL.md is roughly 19k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full. Its references folder adds about 7.3k tokens, read only when the agent opens those files.
Skills that share tags, products or a category with MCP App Builder: Control Chrome (wxtsky/byob, 143 stars), Codex with ChatGPT Planning Loop (XiaoDuoYa/codex-with-chatgpt, 7.2k stars), Cao MCP Apps (awslabs/cli-agent-orchestrator, 1.4k stars) and Chatgpt Apps (Haohao-end/openagent, 791 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
anthropics (a GitHub organization, an official publisher) maintains it in anthropics/claude-plugins-official, which has 37,597 GitHub stars. The repository holds 29 skills in this directory. The repository was last updated on October 9, 2026.
Source: anthropics/claude-plugins-official on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.