feat(test-servers): mcp_app_demo preset + _meta on tool/resource defs (closes #1557)
Add an `mcp_app_demo` tool/resource preset — a self-contained MCP App widget that exercises size-changed, ui/message, log notifications, and host-context rendering with no external server. Requires plumbing an optional `_meta` field through the composable test server's `ToolDefinition` and `ResourceDefinition` so clients can read tool-level `_meta.ui.resourceUri` and resource-level `_meta.ui.csp`. - composable-test-server: optional `_meta` on ToolDefinition/ResourceDefinition, passed through to registerTool config and the resource content item. - test-server-fixtures: `createMcpAppDemoTool()`, `createMcpAppDemoResource()`, inline `MCP_APP_DEMO_HTML` widget; wired into getDefaultServerConfig. - preset-registry: `mcp_app_demo` tool + `mcp_app_demo_widget` resource presets. - integration test asserting the `_meta` plumbing end-to-end. Wave 1 of PR #1510 decomposition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
parent
066676a241
commit
0d50b908a3
3 changed files with 281 additions and 0 deletions
|
|
@ -102,6 +102,8 @@ export interface ToolDefinition {
|
|||
inputSchema?: ToolInputSchema;
|
||||
/** Optional Zod object schema for tool output; when set, handler must return structuredContent. */
|
||||
outputSchema?: unknown;
|
||||
/** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for MCP App tools). */
|
||||
_meta?: Record<string, unknown>;
|
||||
handler: (
|
||||
params: Record<string, unknown>,
|
||||
context?: TestServerContext,
|
||||
|
|
@ -123,6 +125,8 @@ export interface ResourceDefinition {
|
|||
description?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
/** Included on the returned content item so clients can read resource-level `_meta` (e.g. `_meta.ui.csp` for MCP App UI resources). */
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PromptDefinition {
|
||||
|
|
@ -500,6 +504,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
...(tool.outputSchema != null && {
|
||||
outputSchema: tool.outputSchema as AnySchema,
|
||||
}),
|
||||
...(tool._meta != null && { _meta: tool._meta }),
|
||||
},
|
||||
async (args, extra) => {
|
||||
const result = await tool.handler(
|
||||
|
|
@ -565,6 +570,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
uri: resource.uri,
|
||||
mimeType: resource.mimeType || "text/plain",
|
||||
text: resource.text ?? "",
|
||||
...(resource._meta != null && { _meta: resource._meta }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ import {
|
|||
createOptionalTaskTool,
|
||||
createForbiddenTaskTool,
|
||||
createImmediateReturnTaskTool,
|
||||
createMcpAppDemoTool,
|
||||
createMcpAppDemoResource,
|
||||
createArchitectureResource,
|
||||
createTestCwdResource,
|
||||
createTestEnvResource,
|
||||
|
|
@ -153,6 +155,8 @@ function resolveToolPreset(
|
|||
get("name") as string | undefined,
|
||||
Number(get("delayMs")) || undefined,
|
||||
);
|
||||
case "mcp_app_demo":
|
||||
return createMcpAppDemoTool();
|
||||
default:
|
||||
throw new Error(`Unknown tool preset: ${name}`);
|
||||
}
|
||||
|
|
@ -175,6 +179,8 @@ function resolveResourcePreset(
|
|||
return createTestArgvResource();
|
||||
case "numbered_resources":
|
||||
return createNumberedResources(Number(get("count")) || 3);
|
||||
case "mcp_app_demo_widget":
|
||||
return createMcpAppDemoResource();
|
||||
default:
|
||||
throw new Error(`Unknown resource preset: ${name}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -754,6 +754,165 @@ export function createArgsPrompt(
|
|||
};
|
||||
}
|
||||
|
||||
const MCP_APP_DEMO_URI = "ui://demo/widget.html";
|
||||
|
||||
/**
|
||||
* Minimal MCP App widget that exercises the host-side UI protocol surface
|
||||
* (size-changed, ui/message, log notification, host-context render). Kept as a
|
||||
* single inline HTML string with no external scripts so the sandbox CSP's
|
||||
* locked-down defaults are sufficient.
|
||||
*
|
||||
* The lone `rgba(0,0,0,0.06)` is a deliberate exception to the AGENTS.md
|
||||
* color-token rule: this is a self-contained static fixture served into the
|
||||
* sandbox iframe, not a Mantine component, so the `--inspector-*` CSS custom
|
||||
* properties (defined in the web client's App.css) are not in scope here.
|
||||
*/
|
||||
const MCP_APP_DEMO_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>mcp-app-demo widget</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; padding: 16px; }
|
||||
pre { background: rgba(0,0,0,0.06); padding: 8px; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 id="title">mcp-app-demo</h2>
|
||||
<pre id="ctx">waiting for ui/initialize…</pre>
|
||||
<script type="module">
|
||||
// Per spec, ui/initialize is a View→Host REQUEST: the view sends it on
|
||||
// load and the host responds with hostContext + capabilities. The view
|
||||
// then sends notifications/initialized once it has rendered.
|
||||
// Production apps should use @modelcontextprotocol/ext-apps (the App
|
||||
// class handles the handshake and origin discipline internally) — this
|
||||
// fixture is a minimal no-SDK demo of the raw protocol for testing the
|
||||
// host side.
|
||||
const INIT_ID = 1;
|
||||
let nextId = 2;
|
||||
// Captured from the host's ui/initialize RESPONSE; thereafter every send
|
||||
// targets, and every receive is checked against, this exact origin so a
|
||||
// sibling frame on a different origin cannot inject or intercept traffic.
|
||||
let HOST_ORIGIN = null;
|
||||
const send = (msg) =>
|
||||
window.parent.postMessage(
|
||||
{ jsonrpc: "2.0", ...msg },
|
||||
HOST_ORIGIN ?? "*",
|
||||
);
|
||||
let lastCtx = {};
|
||||
const renderCtx = (patch) => {
|
||||
// host-context-changed carries a PARTIAL (only changed fields per
|
||||
// spec); merge into the running snapshot so unchanged fields persist.
|
||||
lastCtx = { ...lastCtx, ...patch };
|
||||
document.getElementById("ctx").textContent = JSON.stringify(
|
||||
{
|
||||
theme: lastCtx.theme,
|
||||
displayMode: lastCtx.displayMode,
|
||||
containerDimensions: lastCtx.containerDimensions,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
const onInitialized = (hostContext) => {
|
||||
renderCtx(hostContext);
|
||||
// Signal the view is ready (host gates view→host requests on this).
|
||||
send({ method: "ui/notifications/initialized" });
|
||||
// Standard MCP log notification — surfaced by the host's log panel.
|
||||
send({
|
||||
method: "notifications/message",
|
||||
params: { level: "info", data: "mcp-app-demo initialized" },
|
||||
});
|
||||
// Tell the host the rendered content height.
|
||||
send({
|
||||
method: "ui/notifications/size-changed",
|
||||
params: {
|
||||
width: document.body.clientWidth,
|
||||
height: document.body.scrollHeight,
|
||||
},
|
||||
});
|
||||
// Submit one user-role message via ui/message.
|
||||
send({
|
||||
id: nextId++,
|
||||
method: "ui/message",
|
||||
params: {
|
||||
content: [{ type: "text", text: "hello from mcp-app-demo" }],
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("message", (ev) => {
|
||||
if (HOST_ORIGIN !== null && ev.origin !== HOST_ORIGIN) return;
|
||||
const m = ev.data;
|
||||
if (!m || m.jsonrpc !== "2.0") return;
|
||||
if (m.id === INIT_ID && m.result) {
|
||||
HOST_ORIGIN = ev.origin;
|
||||
onInitialized(m.result.hostContext);
|
||||
} else if (m.method === "ui/notifications/host-context-changed") {
|
||||
// params IS the partial McpUiHostContext (spec.types.d.ts:290).
|
||||
renderCtx(m.params);
|
||||
}
|
||||
});
|
||||
// Kick off the handshake.
|
||||
send({
|
||||
id: INIT_ID,
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: "2026-01-26",
|
||||
appInfo: { name: "mcp-app-demo", version: "1.0.0" },
|
||||
appCapabilities: {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/**
|
||||
* Tool definition for the MCP App demo. Carries `_meta.ui.resourceUri` so
|
||||
* clients recognize it as an App tool; the call result echoes the input title
|
||||
* so the rendered widget can be visually correlated with the call.
|
||||
*/
|
||||
export function createMcpAppDemoTool(): ToolDefinition {
|
||||
return {
|
||||
name: "mcp_app_demo",
|
||||
description:
|
||||
"Render a minimal MCP App widget that exercises size-changed, ui/message, logging, and host-context rendering.",
|
||||
inputSchema: {
|
||||
title: z.string().describe("Heading shown in the rendered widget"),
|
||||
},
|
||||
_meta: {
|
||||
ui: { resourceUri: MCP_APP_DEMO_URI, visibility: ["model", "app"] },
|
||||
},
|
||||
handler: async (params: Record<string, unknown>) => {
|
||||
return toToolResult(
|
||||
`mcp_app_demo rendered with title="${String(params.title)}"`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UI resource for {@link createMcpAppDemoTool}. Declares a permissive
|
||||
* `_meta.ui.csp` (no external connect/resource domains) and a sample
|
||||
* `permissions` block so `--app-info` and the host's CSP enforcement both have
|
||||
* something to read.
|
||||
*/
|
||||
export function createMcpAppDemoResource(): ResourceDefinition {
|
||||
return {
|
||||
name: "mcp_app_demo_widget",
|
||||
uri: MCP_APP_DEMO_URI,
|
||||
description: "Inline HTML widget for the mcp_app_demo tool",
|
||||
mimeType: "text/html",
|
||||
text: MCP_APP_DEMO_HTML,
|
||||
_meta: {
|
||||
ui: {
|
||||
csp: { connectDomains: [], resourceDomains: [] },
|
||||
permissions: { clipboard: false },
|
||||
prefersBorder: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "architecture" resource definition
|
||||
*/
|
||||
|
|
@ -1883,6 +2042,7 @@ export function getDefaultServerConfig(): ServerConfig {
|
|||
createGetTempExtraTool(),
|
||||
createSendNotificationTool(),
|
||||
createWriteToStderrTool(),
|
||||
createMcpAppDemoTool(),
|
||||
],
|
||||
prompts: [createSimplePrompt(), createArgsPrompt()],
|
||||
resources: [
|
||||
|
|
@ -1890,6 +2050,7 @@ export function getDefaultServerConfig(): ServerConfig {
|
|||
createTestCwdResource(),
|
||||
createTestEnvResource(),
|
||||
createTestArgvResource(),
|
||||
createMcpAppDemoResource(),
|
||||
],
|
||||
resourceTemplates: [
|
||||
createFileResourceTemplate(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue