build-mcp-app
anthropics/claude-plugins-official
이 스킬은 사용자가 “MCP 앱”을 구축하거나, MCP 서버에 “상호작용형 UI”나 “위젯”을 추가하거나, “채팅 내에서 컴포넌트를 렌더링”하거나, “MCP UI 리소스”를 구축하거나, 대화창 내에 “폼”, “피커”, “대시보드” 또는 “확인 대화상자”를 인라인으로 표시하는 도구를 만들거나, MCP와 관련하여 “앱 SDK”를 언급할 때 이 스킬을 사용해야 합니다. build-mcp-server 스킬이 배포 모델을 확정시킨 후, 또는 사용자가 이미 UI 위젯을 원한다는 것을 알고 있는 경우에 사용하십시오.
...모든 것을 확장하십시오MCP 앱 구축 (대화형 UI 위젯)
MCP 앱은 UI 리소스, 즉 채팅 화면에 인라인으로 렌더링되는 대화형 컴포넌트도 제공하는 표준 MCP 서버입니다. 한 번만 구축하면 Claude, ChatGPT 및 앱 화면을 구현하는 기타 모든 호스트에서 실행됩니다.
UI 계층은 추가적인 기능입니다. 내부적으로는 여전히 도구, 리소스 및 동일한 와이어 프로토콜을 사용합니다. 이전에 일반 MCP 서버를 구축해 본 적이 없다면, build-mcp-server 이 스킬은 기본 레이어를 다룹니다. 이 스킬은 그 위에 위젯을 추가합니다.
Claude에서 테스트하기: claude.ai에 서버를 사용자 정의 커넥터로 추가하세요(로컬 개발의 경우 Cloudflare 터널을 통해). 이렇게 하면 실제 iframe 샌드박스를 테스트할 수 있으며
hostContext. https://claude.com/docs/connectors/building/testing를 참조하세요.
Claude 호스트 관련 세부 사항
hostContext.safeAreaInsets: {top, right, bottom, left}(px) — 노치 및 컴포저 오버레이에 대해 이 값을 준수하십시오.- 디렉토리 제출에는 OAuth 또는 인증 없음(
none) — 정적 베어러는 비공개 배포 전용이며 목록 등록을 차단합니다 — 또한 도구annotations및 3~5장의 PNG 스크린샷이 필요합니다; 참조references/directory-checklist.md.
위젯이 일반 텍스트보다 나은 경우
단순히 UI를 추가하기 위해 UI를 넣지 마세요 — 대부분의 도구는 텍스트나 JSON을 반환해도 충분합니다. 다음 조건 중 하나에 해당할 때 위젯을 추가하세요:
해당 사항이 없다면 위젯을 생략하세요. 텍스트는 제작도 빠르고 사용자 입장에서도 더 빠릅니다.
위젯 대 정보 추출 — 올바르게 선택하기
위젯을 구현하기 전에, 정보 추출로 처리할 수 있는지 확인하십시오. 정보 추출은 사양에 내재되어 있으며, UI 코드가 전혀 필요 없고, 호환되는 모든 호스트에서 작동합니다.
정보 추출로 처리할 수 있다면 이를 활용하십시오. 참조: ../build-mcp-server/references/elicitation.md.
아키텍처: 두 가지 배포 형태
원격 MCP 앱 (가장 일반적)
호스팅된 스트리밍 가능한 HTTP 서버. 위젯 템플릿은 리소스로 제공되며, 도구 결과는 이를 참조합니다. 호스트는 리소스를 가져와 iframe 샌드박스에서 렌더링하고, 위젯과 Claude 간의 메시지를 중개합니다.
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
MCPB로 패키징된 MCP 앱(로컬 + UI)
위젯 메커니즘은 동일하지만, 서버는 MCPB 번들 내에서 로컬로 실행됩니다. 위젯이 로컬 애플리케이션을 제어해야 할 때 이 방식을 사용하십시오. 예를 들어, 실제 로컬 디스크를 탐색하는 파일 선택기나 데스크톱 앱을 제어하는 대화 상자 등이 있습니다.
MCPB 패키징 방식에 대해서는 ‘build-mcpb’ 스킬을 참조하십시오. 아래의 모든 내용은 두 형태 모두에 적용됩니다.
위젯이 도구에 연결되는 방식
위젯이 활성화된 도구에는 두 가지 별도의 등록 항목이 있습니다:
- 도구는 다음을 통해 UI 리소스를 선언합니다.
_meta.ui.resourceUri를 통해 UI 리소스를 선언합니다. 해당 핸들러는 HTML이 아닌 일반 텍스트/JSON을 반환합니다. - 리소스는 별도로 등록되며 HTML을 제공합니다.
Claude가 도구를 호출하면 호스트는 _meta.ui.resourceUri를 감지하고, 해당 리소스를 가져와 iframe 내에서 렌더링한 뒤, ontoolresult 이벤트를 통해 도구의 반환 값을 iframe으로 전달합니다.
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
}],
}),
);
URI 스키마 ui:// 는 관례에 따릅니다. MIME 유형은 반드시 RESOURCE_MIME_TYPE ("text/html;profile=mcp-app")이어야 합니다. 호스트는 이를 통해 소스를 단순히 표시하는 것이 아니라 대화형 iframe으로 렌더링해야 함을 알 수 있습니다.
위젯 런타임 — App 클래스
iframe 내부에서, 스크립트는 App 클래스를 통해 호스트와 통신합니다. @modelcontextprotocol/ext-apps를 통해 호스트와 통신합니다. 이는 지속적인 양방향 연결로, 통신이 활성화된 동안 위젯은 계속 유지되며 새로운 도구 결과를 수신하고 사용자 동작을 전송합니다.
<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>
/*__EXT_APPS_BUNDLE__*/ placeholder는 시작 시 서버에 의해 @modelcontextprotocol/ext-apps/app-with-deps — 이것이 필요한 이유와 리라이팅 스니펫은 references/iframe-sandbox.md 에서 이것이 필요한 이유와 리라이팅 스니펫을 확인하세요. import { App } from "https://esm.sh/..."; iframe의 CSP가 전이적 종속성 가져오기를 차단하여 위젯이 비어 있는 상태로 렌더링됩니다.
sendMessage 는 전형적인 “사용자가 무언가를 선택했으니, 클로드에게 알리기” 경로입니다. updateModelContext 는 Claude가 알아야 하지만 채팅을 복잡하게 만들지 않아야 하는 상태를 위한 것입니다. openLink 는 외부 사이트로의 이동 시 필수적입니다 — window.open 그리고 는 샌드박스 속성에 의해 차단됩니다.
위젯이 할 수 없는 것:
- 호스트 페이지의 DOM, 쿠키 또는 스토리지에 접근하는 것
- 임의의 원본에 대한 네트워크 호출 수행 (CSP 제한 — 다음을 통해 경로 설정
callServerTool) - 팝업 창을 열거나 직접 이동 — 다음을 사용하십시오
app.openLink({url}) - 원격 이미지를 안정적으로 불러오기 — 서버 측에서
data:URL로 서버 측에서 인라인 처리
위젯은 작고 단일 용도로 유지하십시오. 선택기는 선택하고, 차트는 표시합니다. iframe 내에 전체 서브 앱을 구축하지 말고, 특정 기능에 집중된 위젯을 사용하여 여러 도구로 분할하십시오.
스캐폴드: 최소한의 피커 위젯
설치:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
서버 (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);
로컬 전용 위젯 앱(데스크톱 앱 구동, 로컬 파일 읽기)의 경우, 전송 방식을 StdioServerTransport 로 변경하고 build-mcpb skill을 사용하여 패키징하십시오.
위젯(widgets/picker.html):
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 = `${c.name}${c.email}`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
}; await app.connect();
})();
script>
더 많은 위젯 모양을 보려면 references/widget-templates.md 를 참조하십시오.
재작성 수고를 덜어주는 디자인 팁
도구당 하나의 위젯을 사용하세요. 모든 기능을 수행하는 하나의 초대형 위젯을 만들고 싶은 유혹을 참으세요. 하나의 도구 → 하나의 집중된 위젯 → 하나의 명확한 결과 형태. 클로드는 이를 훨씬 더 잘 추론합니다.
도구 설명에는 위젯을 반드시 명시해야 합니다. 클로드는 호출 대상을 결정할 때 도구 설명만 참조합니다. 설명에 “대화형 선택기를 엽니다”라고 명시되어 있어야만, 클로드가 ID를 추측하는 대신 해당 도구를 선택합니다.
위젯은 런타임 시 선택 사항입니다. 앱 표면을 지원하지 않는 호스트는 단순히 이를 무시하고 _meta.ui 해당 도구의 텍스트 콘텐츠를 정상적으로 렌더링합니다. 도구 핸들러가 이미 의미 있는 텍스트/JSON(위젯 데이터)을 반환하므로 성능 저하에 대한 처리는 자동으로 이루어집니다. 즉, Claude는 위젯을 거치지 않고 데이터를 직접 확인합니다.
읽기 전용 도구의 경우 위젯 결과를 기다리며 처리 과정을 중단하지 마세요. 단순히 데이터를 표시하는 위젯(차트, 미리보기)은 완료하기 위해 사용자 동작을 요구해서는 안 됩니다. Claude가 기다리지 않고 추론을 계속할 수 있도록, 표시용 위젯과 텍스트 요약 정보를 동일한 결과에 함께 반환하세요.
도구 수가 아닌 항목 수에 따라 레이아웃을 분기하십시오. 한 사용 사례가 "결과 하나를 상세히 표시"이고 다른 사용 사례가 "여러 결과를 나란히 표시"인 경우, 두 개의 도구를 만들지 말고 items[]를 처리할 수 있는 하나의 도구를 만들고, 위젯이 레이아웃을 선택하도록 하십시오: items.length === 1 → 상세 보기, > 1 → 캐러셀. 이렇게 하면 서버 스키마를 단순하게 유지할 수 있으며, Claude가 자연스럽게 개수를 결정할 수 있습니다.
Claude의 추론 결과를 페이로드에 포함시키세요. 각 항목에 대한 짧은 note 항목마다 (Claude가 해당 항목을 선택한 이유)를 설명하는 짧은 필드를 카드의 콜아웃 형태로 렌더링하면, 사용자에게 선택 사항과 연계된 추론 과정을 인라인으로 제공할 수 있습니다. Claude가 이 필드를 채울 수 있도록 도구 설명에 이 필드를 명시하세요.
서버 측에서 이미지 모양을 정규화하세요. 데이터 소스가 종횡비가 매우 다양한 이미지를 반환하는 경우, 인라인 데이터 URL로 불러오기 전에 예측 가능한 변형(예: 정사각형 경계)으로 재작성하세요. 그런 다음 위젯의 이미지 컨테이너에 고정된 aspect-ratio + object-fit: contain 값을 지정하여 모든 요소가 중앙에 정렬되도록 하세요.
호스트 테마를 따르십시오. app.getHostContext()?.theme (다음 connect()) 및 app.onhostcontextchanged 실시간 업데이트를 위해. .dark 클래스를 토글하고 , CSS 사용자 정의 프로퍼티의 색상은 :root.dark {} 오버라이드 블록에 저장하고, color-scheme. 다크 모드에서는 mix-blend-mode: multiply 다크 모드에서는 이미지가 사라지므로 비활성화하세요.
테스트
Claude Desktop — 현재 빌드에서는 여전히 command/args 구성 모양이 필요합니다(네이티브 "type": "http"). mcp-remote 로 감싸고 http-only 전송 방식을 강제하여 SSE 프로브가 위젯 기능 협상을 가로채지 않도록 하십시오:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}
Desktop은 UI 리소스를 적극적으로 캐시합니다. 위젯 HTML을 편집한 후에는 완전히 종료(⌘Q / Alt+F4, 창 닫기가 아님)한 뒤 다시 실행하여 리소스를 완전히 새로 불러오도록 강제하십시오.
헤드리스 JSON-RPC 루프 — 데스크톱을 클릭하지 않고도 빠르게 반복 테스트하기:
# 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-http
` sleep 는 모든 응답을 수집할 수 있을 만큼 충분히 오랫동안 stdin을 열어 둡니다. jsonl 출력을 jq 또는 Python 원라인 명령어로 jsonl 출력을 파싱하세요.
위젯 개발 루프 — 가짜 ExtApps shim을 사용하여 ontoolresult 쿼리 매개변수에서 호출되는 가짜심(shim)을 사용하여:
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));
});
일반 브라우저 탭에서 http://localhost:3000/widget-preview?payload={"rows":[...]} 일반 브라우저 탭에서 열고 일반 개발자 도구로 반복 테스트하세요.
호스트 폴백 — 앱 인터페이스(또는 MCP Inspector)가 없는 호스트를 사용하여 도구의 텍스트 콘텐츠가 원활하게 축소되는지 확인하세요.
CSP 디버깅 — iframe 자체의 개발자 도구 콘솔을 엽니다. CSP 위반은 위젯이 아무런 오류 메시지 없이 실패하는(빈 사각형이 표시되고 메인 콘솔에 오류가 없는) 가장 큰 원인입니다. references/iframe-sandbox.md.
참조 파일
references/iframe-sandbox.md— CSP/샌드박스 제약 조건, 번들 인라인 처리 패턴, 이미지 처리, 호스트 테마 적용references/widget-templates.md— 피커 / 확인 / 진행 상황 / 표시를 위한 재사용 가능한 HTML 스캐폴드references/apps-sdk-messages.md—App클래스 API: 위젯 ↔ 호스트 ↔ 서버 간 메시징, 라이프사이클 및 대체references/payload-budgeting.md— 호스트 도구 결과 크기 제한, ‘정리 후 잘라내기(prune-then-truncate)’,callServerToolreferences/abuse-protection.md— Anthropic 아웃바운드 CIDR, 계층형 속도 제한,trust proxy, 응답 캐싱references/directory-checklist.md— 커넥터 디렉터리 제출을 위한 사전 검증
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.
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.
Claude host specifics
hostContext.safeAreaInsets: {top, right, bottom, left}(px) — honor these for notches and the composer overlay.- Directory submission requires OAuth or authless (
none) — static bearer is private-deploy only and blocks listing — plus toolannotationsand 3–5 PNG screenshots; seereferences/directory-checklist.md.
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:
If none apply, skip the widget. Text is faster to build and faster for the user.
Widgets vs Elicitation — route correctly
Before building a widget, check if elicitation covers it. Elicitation is spec-native, zero UI code, works in any compliant host.
If elicitation covers it, use it. See ../build-mcp-server/references/elicitation.md.
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 widget-enabled tool has two separate registrations:
- The tool declares a UI resource via
_meta.ui.resourceUri. Its handler returns plain text/JSON — NOT the HTML. - The resource is registered separately and serves 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.
Widget runtime — the App class
Inside 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.
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:
- Access the host page's DOM, cookies, or storage
- Make network calls to arbitrary origins (CSP-restricted — route through
callServerTool) - Open popups or navigate directly — use
app.openLink({url}) - Load remote images reliably — inline as
data:URLs server-side
Keep 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.
Scaffold: minimal picker widget
Install:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
Server (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.
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 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.
Testing
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-http
The 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.
Reference files
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— theAppclass API: widget ↔ host ↔ server messaging, lifecycle & supersessionreferences/payload-budgeting.md— host tool-result size caps, prune-then-truncate, heavy assets viacallServerToolreferences/abuse-protection.md— Anthropic egress CIDRs, tiered rate limiting,trust proxy, response cachingreferences/directory-checklist.md— pre-flight for connector-directory submission
모든 파일
7개 파일build-mcp-app 설치
스킬 파일을 다운로드하여 .claude/skills/ 디렉터리에 압축을 풀어주세요.
ZIP 다운로드저장소를 클론하고 스킬 파일을 프로젝트에 복사하세요.
git clone https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app # Copy the skill folder to .claude/skills/ or .codex/skills/
복사





집
