diff --git a/clients/web/src/test/integration/mcp/mcp-app-demo.test.ts b/clients/web/src/test/integration/mcp/mcp-app-demo.test.ts new file mode 100644 index 00000000..4da5d92c --- /dev/null +++ b/clients/web/src/test/integration/mcp/mcp-app-demo.test.ts @@ -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 { + 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"'); + }); +}); diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 44ca6057..55ebb9a4 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -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; handler: ( params: Record, 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; } 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 }), }, ], }; diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 5e2f3ea9..51dd2db7 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -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}`); } diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 95c8cbed..80874b28 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -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 = ` + + + + mcp-app-demo widget + + + +

mcp-app-demo

+
waiting for ui/initialize…
+ + +`; + +/** + * 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) => { + 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(),