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
4 changed files with 281 additions and 0 deletions
108
clients/web/src/test/integration/mcp/mcp-app-demo.test.ts
Normal file
108
clients/web/src/test/integration/mcp/mcp-app-demo.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -102,6 +102,8 @@ export interface ToolDefinition {
|
||||||
inputSchema?: ToolInputSchema;
|
inputSchema?: ToolInputSchema;
|
||||||
/** Optional Zod object schema for tool output; when set, handler must return structuredContent. */
|
/** Optional Zod object schema for tool output; when set, handler must return structuredContent. */
|
||||||
outputSchema?: unknown;
|
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: (
|
handler: (
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
context?: TestServerContext,
|
context?: TestServerContext,
|
||||||
|
|
@ -123,6 +125,8 @@ export interface ResourceDefinition {
|
||||||
description?: string;
|
description?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
text?: 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 {
|
export interface PromptDefinition {
|
||||||
|
|
@ -500,6 +504,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
||||||
...(tool.outputSchema != null && {
|
...(tool.outputSchema != null && {
|
||||||
outputSchema: tool.outputSchema as AnySchema,
|
outputSchema: tool.outputSchema as AnySchema,
|
||||||
}),
|
}),
|
||||||
|
...(tool._meta != null && { _meta: tool._meta }),
|
||||||
},
|
},
|
||||||
async (args, extra) => {
|
async (args, extra) => {
|
||||||
const result = await tool.handler(
|
const result = await tool.handler(
|
||||||
|
|
@ -565,6 +570,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
mimeType: resource.mimeType || "text/plain",
|
mimeType: resource.mimeType || "text/plain",
|
||||||
text: resource.text ?? "",
|
text: resource.text ?? "",
|
||||||
|
...(resource._meta != null && { _meta: resource._meta }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ import {
|
||||||
createOptionalTaskTool,
|
createOptionalTaskTool,
|
||||||
createForbiddenTaskTool,
|
createForbiddenTaskTool,
|
||||||
createImmediateReturnTaskTool,
|
createImmediateReturnTaskTool,
|
||||||
|
createMcpAppDemoTool,
|
||||||
|
createMcpAppDemoResource,
|
||||||
createArchitectureResource,
|
createArchitectureResource,
|
||||||
createTestCwdResource,
|
createTestCwdResource,
|
||||||
createTestEnvResource,
|
createTestEnvResource,
|
||||||
|
|
@ -153,6 +155,8 @@ function resolveToolPreset(
|
||||||
get("name") as string | undefined,
|
get("name") as string | undefined,
|
||||||
Number(get("delayMs")) || undefined,
|
Number(get("delayMs")) || undefined,
|
||||||
);
|
);
|
||||||
|
case "mcp_app_demo":
|
||||||
|
return createMcpAppDemoTool();
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool preset: ${name}`);
|
throw new Error(`Unknown tool preset: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
@ -175,6 +179,8 @@ function resolveResourcePreset(
|
||||||
return createTestArgvResource();
|
return createTestArgvResource();
|
||||||
case "numbered_resources":
|
case "numbered_resources":
|
||||||
return createNumberedResources(Number(get("count")) || 3);
|
return createNumberedResources(Number(get("count")) || 3);
|
||||||
|
case "mcp_app_demo_widget":
|
||||||
|
return createMcpAppDemoResource();
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown resource preset: ${name}`);
|
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
|
* Create an "architecture" resource definition
|
||||||
*/
|
*/
|
||||||
|
|
@ -1883,6 +2042,7 @@ export function getDefaultServerConfig(): ServerConfig {
|
||||||
createGetTempExtraTool(),
|
createGetTempExtraTool(),
|
||||||
createSendNotificationTool(),
|
createSendNotificationTool(),
|
||||||
createWriteToStderrTool(),
|
createWriteToStderrTool(),
|
||||||
|
createMcpAppDemoTool(),
|
||||||
],
|
],
|
||||||
prompts: [createSimplePrompt(), createArgsPrompt()],
|
prompts: [createSimplePrompt(), createArgsPrompt()],
|
||||||
resources: [
|
resources: [
|
||||||
|
|
@ -1890,6 +2050,7 @@ export function getDefaultServerConfig(): ServerConfig {
|
||||||
createTestCwdResource(),
|
createTestCwdResource(),
|
||||||
createTestEnvResource(),
|
createTestEnvResource(),
|
||||||
createTestArgvResource(),
|
createTestArgvResource(),
|
||||||
|
createMcpAppDemoResource(),
|
||||||
],
|
],
|
||||||
resourceTemplates: [
|
resourceTemplates: [
|
||||||
createFileResourceTemplate(),
|
createFileResourceTemplate(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue