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:
cliffhall 2026-07-01 18:22:25 -04:00
commit 0d50b908a3
4 changed files with 281 additions and 0 deletions

View file

@ -0,0 +1,108 @@
import { describe, it, expect, afterEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createMcpAppDemoTool,
createMcpAppDemoResource,
} from "@modelcontextprotocol/inspector-test-server";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
const MCP_APP_DEMO_URI = "ui://demo/widget.html";
/**
* Exercises the `mcp_app_demo` preset added in #1557 end-to-end: it confirms the
* `_meta` plumbing through the composable test server's tool/resource
* definitions surfaces on `tools/list` and `resources/read`, which is what the
* downstream Apps-host and CLI `--app-info` tests rely on.
*/
describe("mcp_app_demo preset", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// Ignore disconnect errors
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// Ignore server stop errors
}
server = null;
}
});
async function connect(): Promise<InspectorClient> {
server = createTestServerHttp({
serverInfo: createTestServerInfo("mcp-app-demo-test", "1.0.0"),
tools: [createMcpAppDemoTool()],
resources: [createMcpAppDemoResource()],
});
await server.start();
const connected = new InspectorClient(
{ type: "streamable-http", url: server.url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("exposes tool-level _meta.ui.resourceUri on tools/list", async () => {
const connected = await connect();
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mcp_app_demo");
expect(tool).toBeDefined();
const meta = (tool as Tool)._meta as
| { ui?: { resourceUri?: string; visibility?: string[] } }
| undefined;
expect(meta?.ui?.resourceUri).toBe(MCP_APP_DEMO_URI);
expect(meta?.ui?.visibility).toEqual(["model", "app"]);
});
it("exposes resource-level _meta.ui on resources/read", async () => {
const connected = await connect();
const invocation = await connected.readResource(MCP_APP_DEMO_URI);
const contents = invocation.result.contents;
expect(contents).toHaveLength(1);
const entry = contents[0];
expect(entry.mimeType).toBe("text/html");
expect("text" in entry ? String(entry.text) : "").toContain("mcp-app-demo");
const meta = entry._meta as
| {
ui?: {
csp?: { connectDomains?: string[]; resourceDomains?: string[] };
permissions?: { clipboard?: boolean };
prefersBorder?: boolean;
};
}
| undefined;
expect(meta?.ui?.csp).toEqual({
connectDomains: [],
resourceDomains: [],
});
expect(meta?.ui?.permissions).toEqual({ clipboard: false });
expect(meta?.ui?.prefersBorder).toBe(true);
});
it("echoes the input title from the tool handler", async () => {
const connected = await connect();
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mcp_app_demo")!;
const result = await connected.callTool(tool, { title: "Widget A" });
expect(result.success).toBe(true);
const content = result.result!.content;
const text =
content[0] && "text" in content[0] ? String(content[0].text) : "";
expect(text).toBe('mcp_app_demo rendered with title="Widget A"');
});
});

View file

@ -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 }),
},
],
};

View file

@ -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}`);
}

View file

@ -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(),