From 1a6e32be50079fb9a2969b31a04f780c901ccae4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 18:20:13 -0400 Subject: [PATCH 1/2] feat(core): add AppInfo + extractAppInfo() to core/mcp/apps.ts (closes #1556) Add a machine-readable summary of an MCP App's UI metadata: the AppInfo interface plus extractAppInfo(), which merges a tool's _meta.ui (resourceUri, visibility) with the linked UI resource's _meta.ui (csp, permissions, domain, prefersBorder, mimeType). Content-block matching is exact-URI first, then a lowercase + trailing-slash normalized match, then a single-block fallback since resources/read returns the requested resource by definition. Shared plumbing for the programmatic-review path (CLI --app-info, integration tests) without rendering the app. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/web/src/test/core/apps.test.ts | 157 ++++++++++++++++++++++++- core/mcp/apps.ts | 123 ++++++++++++++++++- 2 files changed, 277 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/apps.test.ts b/clients/web/src/test/core/apps.test.ts index 466d2746..5e518a5a 100644 --- a/clients/web/src/test/core/apps.test.ts +++ b/clients/web/src/test/core/apps.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "vitest"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { getAppResourceUri, isAppTool } from "@inspector/core/mcp/apps.js"; +import type { + ReadResourceResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { + extractAppInfo, + getAppResourceUri, + isAppTool, +} from "@inspector/core/mcp/apps.js"; const NESTED_URI = "ui://demo/widget"; const FLAT_URI = "ui://legacy/widget"; @@ -92,3 +99,149 @@ describe("isAppTool", () => { ).toThrow(/Invalid UI resource URI/); }); }); + +describe("extractAppInfo", () => { + const APP_TOOL = toolWith({ + ui: { resourceUri: NESTED_URI, visibility: ["model", "app"] }, + }); + + const resourceWith = ( + ui: Record | undefined, + ): ReadResourceResult => ({ + contents: [ + { + uri: NESTED_URI, + mimeType: "text/html", + text: "", + ...(ui ? { _meta: { ui } } : {}), + }, + ], + }); + + it("returns hasApp:false for a non-App tool", () => { + expect(extractAppInfo(toolWith(undefined))).toEqual({ + hasApp: false, + toolName: "demo", + }); + }); + + it("returns tool-side info (resourceUri, visibility) when no resource is supplied", () => { + expect(extractAppInfo(APP_TOOL)).toEqual({ + hasApp: true, + toolName: "demo", + resourceUri: NESTED_URI, + visibility: ["model", "app"], + }); + }); + + it("merges resource-side csp/permissions/domain/prefersBorder/mimeType from the matched content item", () => { + const csp = { connectDomains: ["https://api.example.com"] }; + const permissions = { clipboard: true }; + const info = extractAppInfo( + APP_TOOL, + resourceWith({ csp, permissions, domain: "abc.example.net" }), + ); + expect(info).toEqual({ + hasApp: true, + toolName: "demo", + resourceUri: NESTED_URI, + visibility: ["model", "app"], + csp, + permissions, + domain: "abc.example.net", + resourceMimeType: "text/html", + }); + }); + + it("falls back to result-level _meta.ui when no content item matches the URI", () => { + const result: ReadResourceResult = { + contents: [{ uri: "ui://other", text: "" }], + _meta: { ui: { domain: "fallback.example.net" } }, + }; + expect(extractAppInfo(APP_TOOL, result).domain).toBe( + "fallback.example.net", + ); + }); + + it("includes prefersBorder:false when explicitly set", () => { + expect( + extractAppInfo(APP_TOOL, resourceWith({ prefersBorder: false })) + .prefersBorder, + ).toBe(false); + }); + + it("propagates the underlying throw for a malformed resourceUri", () => { + expect(() => + extractAppInfo(toolWith({ ui: { resourceUri: "https://x" } })), + ).toThrow(/Invalid UI resource URI/); + }); + + describe("resource-content URI matching", () => { + const csp = { connectDomains: ["https://api.example.com"] }; + + function resourceAt( + uris: string[], + uiOn: number | "result" = 0, + ): ReadResourceResult { + return { + contents: uris.map((uri, i) => ({ + uri, + mimeType: "text/html", + text: "", + ...(uiOn === i ? { _meta: { ui: { csp } } } : {}), + })), + ...(uiOn === "result" ? { _meta: { ui: { csp } } } : {}), + }; + } + + it("finds the content block on exact URI match", () => { + expect(extractAppInfo(APP_TOOL, resourceAt([NESTED_URI])).csp).toEqual( + csp, + ); + }); + + it("finds the content block when the server's URI differs only by case or trailing slash", () => { + const tool = toolWith({ + ui: { resourceUri: "ui://Demo/Widget", visibility: ["app"] }, + }); + // Two blocks so the single-block fallback can't paper over a failed + // normalize — the match must be on the normalized URI. + const info = extractAppInfo( + tool, + resourceAt(["ui://other/x", "ui://demo/widget/"], 1), + ); + expect(info.csp).toEqual(csp); + expect(info.resourceMimeType).toBe("text/html"); + }); + + it("falls back to the sole content block when the URI does not match — resources/read returns the requested resource by definition", () => { + const info = extractAppInfo( + APP_TOOL, + resourceAt(["ui://demo/widget?v=2"]), + ); + expect(info.csp).toEqual(csp); + }); + + it("does not pick an arbitrary block when multiple non-matching blocks are returned", () => { + const info = extractAppInfo( + APP_TOOL, + resourceAt(["ui://other/a", "ui://other/b"], "result"), + ); + // No content match → no resourceMimeType from a content block … + expect(info.resourceMimeType).toBeUndefined(); + // … but the result-level _meta.ui still flows through. + expect(info.csp).toEqual(csp); + }); + + it("normalize is just lowercase + trailing-slash strip; an empty URI does not match", () => { + const result: ReadResourceResult = { + contents: [ + { uri: "", text: "x", _meta: { ui: { csp } } }, + { uri: "ui://other", text: "y" }, + ], + }; + const info = extractAppInfo(APP_TOOL, result); + expect(info.csp).toBeUndefined(); + }); + }); +}); diff --git a/core/mcp/apps.ts b/core/mcp/apps.ts index 1729fb46..37198e52 100644 --- a/core/mcp/apps.ts +++ b/core/mcp/apps.ts @@ -1,5 +1,8 @@ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { + ReadResourceResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; /** * Returns the UI resource URI advertised by an MCP App tool, or `undefined` @@ -35,3 +38,121 @@ export function getAppResourceUri(tool: Tool): string | undefined { export function isAppTool(tool: Tool): boolean { return getAppResourceUri(tool) !== undefined; } + +/** + * Machine-readable summary of an MCP App's UI metadata, combining what the + * tool advertises (`resourceUri`, `visibility`) with what the UI resource + * itself declares (`csp`, `permissions`, `domain`, `prefersBorder`). Emitted by + * the CLI's `--app-info` mode and reusable by any client that needs to surface + * an app's security posture without rendering it. + */ +export interface AppInfo { + hasApp: boolean; + toolName: string; + resourceUri?: string; + visibility?: readonly string[]; + /** From the UI resource's `_meta.ui` (per spec, csp/permissions/domain live on the resource, not the tool). Absent when the resource was not read. */ + csp?: Readonly>; + permissions?: Readonly>; + domain?: string; + prefersBorder?: boolean; + resourceMimeType?: string; +} + +type WithUiMeta = { _meta?: { ui?: unknown } }; + +/** + * Structural shape for `_meta.ui` carriers — the ext-apps package's named + * types (`McpUiToolMeta`, `McpUiResourceMeta`) are not importable under + * NodeNext module resolution because of an extensionless re-export in the + * package's `.d.ts`, so we read the fields structurally instead. The values + * pass through verbatim into {@link AppInfo}; callers can narrow them against + * the published Zod schemas if they need strict typing. + */ +interface UiMetaShape { + visibility?: readonly string[]; + csp?: Record; + permissions?: Record; + domain?: string; + prefersBorder?: boolean; +} + +function readUiMeta(carrier: WithUiMeta | undefined): UiMetaShape | undefined { + const ui = carrier?._meta?.ui; + return ui !== null && typeof ui === "object" + ? (ui as UiMetaShape) + : undefined; +} + +/** + * Normalizes a `ui://…` URI for comparison so trivial server-side differences + * (case, trailing slash) don't cause us to miss the resource block and silently + * drop its `_meta.ui` (csp/permissions/domain). `ui://` is a non-special scheme + * so the WHATWG URL parser preserves case and trailing slashes verbatim — we + * apply a targeted lowercase + single trailing-slash strip instead. + */ +function normalizeResourceUri(uri: string): string { + const lowered = uri.toLowerCase(); + return lowered.endsWith("/") ? lowered.slice(0, -1) : lowered; +} + +/** + * Locates the content block in a `resources/read` result that corresponds to + * the requested UI resource. Tries exact match, then normalized-URI match, then + * falls back to the sole content block when there is only one — `resources/read` + * by definition returns the content for the URI we asked for, so a single-block + * response is the right block even when the server echoes the URI back in a + * slightly different form. + */ +function findResourceContent( + resource: ReadResourceResult, + requestedUri: string, +): ReadResourceResult["contents"][number] | undefined { + const exact = resource.contents.find((c) => c.uri === requestedUri); + if (exact) return exact; + const norm = normalizeResourceUri(requestedUri); + const normalized = resource.contents.find( + (c) => normalizeResourceUri(c.uri) === norm, + ); + if (normalized) return normalized; + return resource.contents.length === 1 ? resource.contents[0] : undefined; +} + +/** + * Build an {@link AppInfo} from a tool definition and (optionally) the + * `resources/read` result for its UI resource. Per the spec, `csp` / + * `permissions` / `domain` belong on the resource — not the tool — so a caller + * that wants the security posture must read the resource and pass it here. + * + * Returns `{ hasApp: false, toolName }` for non-App tools. Throws on a + * malformed `resourceUri` for the same reason {@link getAppResourceUri} does. + */ +export function extractAppInfo( + tool: Tool, + resource?: ReadResourceResult, +): AppInfo { + const resourceUri = getAppResourceUri(tool); + if (resourceUri === undefined) { + return { hasApp: false, toolName: tool.name }; + } + const toolUi = readUiMeta(tool as WithUiMeta); + const content = resource && findResourceContent(resource, resourceUri); + const resourceUi = + readUiMeta(content as WithUiMeta | undefined) ?? + readUiMeta(resource as WithUiMeta | undefined); + return { + hasApp: true, + toolName: tool.name, + resourceUri, + ...(toolUi?.visibility ? { visibility: toolUi.visibility } : {}), + ...(resourceUi?.csp ? { csp: resourceUi.csp } : {}), + ...(resourceUi?.permissions ? { permissions: resourceUi.permissions } : {}), + ...(resourceUi?.domain ? { domain: resourceUi.domain } : {}), + ...(resourceUi?.prefersBorder !== undefined + ? { prefersBorder: resourceUi.prefersBorder } + : {}), + ...(typeof content?.mimeType === "string" + ? { resourceMimeType: content.mimeType } + : {}), + }; +} From ee4b232a1f1c42d94d91d12da39e5c23d385e542 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 19:14:16 -0400 Subject: [PATCH 2/2] refactor(core): narrow readUiMeta from unknown; document ui-meta precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address round-1 review nits on apps.ts: - readUiMeta now accepts `unknown` and narrows internally, removing the three `as WithUiMeta` casts at its call sites (readability win). - Add a comment documenting that the content-block-then-result precedence for `_meta.ui` is intentional (posture lives on the content block; no shallow merge). - Add a TODO to switch to the named McpUiToolMeta/McpUiResourceMeta types once the upstream extensionless re-export is fixed for NodeNext. Coverage: apps.ts 100% lines / 97.05% branch / 100% funcs — clears the gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- core/mcp/apps.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/core/mcp/apps.ts b/core/mcp/apps.ts index 37198e52..937acc73 100644 --- a/core/mcp/apps.ts +++ b/core/mcp/apps.ts @@ -68,6 +68,9 @@ type WithUiMeta = { _meta?: { ui?: unknown } }; * package's `.d.ts`, so we read the fields structurally instead. The values * pass through verbatim into {@link AppInfo}; callers can narrow them against * the published Zod schemas if they need strict typing. + * + * TODO: switch to the named `McpUiToolMeta` / `McpUiResourceMeta` types once + * upstream fixes the extensionless re-export so they resolve under NodeNext. */ interface UiMetaShape { visibility?: readonly string[]; @@ -77,8 +80,16 @@ interface UiMetaShape { prefersBorder?: boolean; } -function readUiMeta(carrier: WithUiMeta | undefined): UiMetaShape | undefined { - const ui = carrier?._meta?.ui; +/** + * Reads a carrier's `_meta.ui` structurally, narrowing from `unknown` so call + * sites don't need their own casts. Returns `undefined` unless both the carrier + * and its `_meta.ui` are non-null objects. + */ +function readUiMeta(carrier: unknown): UiMetaShape | undefined { + const ui = + carrier !== null && typeof carrier === "object" + ? (carrier as WithUiMeta)._meta?.ui + : undefined; return ui !== null && typeof ui === "object" ? (ui as UiMetaShape) : undefined; @@ -135,11 +146,14 @@ export function extractAppInfo( if (resourceUri === undefined) { return { hasApp: false, toolName: tool.name }; } - const toolUi = readUiMeta(tool as WithUiMeta); + const toolUi = readUiMeta(tool); const content = resource && findResourceContent(resource, resourceUri); - const resourceUi = - readUiMeta(content as WithUiMeta | undefined) ?? - readUiMeta(resource as WithUiMeta | undefined); + // Precedence is intentional: prefer the matched content block's `_meta.ui`, + // falling back to the result-level `_meta.ui`. Per the current spec the + // security posture (csp/permissions/domain) lives on the content block, so we + // don't shallow-merge the two carriers — a content block that declares any + // `ui` is treated as authoritative for all of its fields. + const resourceUi = readUiMeta(content) ?? readUiMeta(resource); return { hasApp: true, toolName: tool.name,