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;
|
||||
/** 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