diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index b14cd1d6..4965d15b 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -245,12 +245,7 @@ export function AppRenderer({ if (pendingResultRef.current !== null) { const result = pendingResultRef.current; pendingResultRef.current = null; - // ext-apps' AppBridge peers on SDK v1's CallToolResult (whose - // `structuredContent` is typed narrower than v2's). Runtime-compatible; - // cast at this boundary. TODO: drop when ext-apps#702 ships a v2 peer. - void bridge.sendToolResult( - result as Parameters[0], - ); + void bridge.sendToolResult(result); } }, []); diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts index 9dbac057..2585026a 100644 --- a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts @@ -5,7 +5,7 @@ import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; * The app capabilities exactly as the view sent them, per bridge. * * `AppBridge.getAppCapabilities()` cannot be used for `elicitation` (#1854): - * ext-apps 1.7.5 parses the view's `ui/initialize` params through + * ext-apps 2.0.0 parses the view's `ui/initialize` params through * `McpUiAppCapabilitiesSchema`, a plain Zod object, so any key that schema does * not declare is **stripped before the bridge stores it**. `elicitation` is * exactly such a key — it is what ext-apps#733 adds — so an app that correctly @@ -25,10 +25,10 @@ const rawAppCapabilities = new WeakMap>(); /** * The only part of a transport this needs: the inbound-message callback. * - * Structural, and generic over the message type, on purpose. ext-apps' - * `PostMessageTransport` implements the SDK *v1* `Transport` — a different - * nominal type from the v2 client's, though runtime-identical — so naming - * either would force a cast at the call site. Generic rather than + * Structural, and generic over the message type, on purpose. This helper + * touches one optional property, so naming the SDK `Transport` (which ext-apps' + * `PostMessageTransport` implements since 2.0.0) would only make every test + * fake carry `start`/`send`/`close` it never calls. Generic rather than * `(message: unknown)` because a handler typed for a narrower message is not * assignable to one typed for `unknown` (contravariance), which would put the * cast back; `rest` is `never[]` for the same reason, accepting any trailing diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 00a67983..b8a38236 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -39,9 +39,7 @@ import type { AppRenderSource, BridgeFactory } from "./AppRenderer"; function resolveSourceUri(source: AppRenderSource): string | undefined { return source.kind === "resource" ? source.resourceUri - : getToolUiResourceUri( - source.tool as Parameters[0], - ); + : getToolUiResourceUri(source.tool); } /** @@ -324,7 +322,7 @@ export function createAppBridgeFactory( // declare its own csp/permissions. const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES, - // `elicitation` is not part of ext-apps 1.7.5's `McpUiHostCapabilities` + // `elicitation` is not part of ext-apps 2.0.0's `McpUiHostCapabilities` // (ext-apps#733 adds it), so it is spread in as an extra key. The bridge // forwards the capabilities object verbatim in its `ui/initialize` // response, which is exactly what the app reads. TODO: drop the cast when @@ -333,17 +331,9 @@ export function createAppBridgeFactory( ? ({ elicitation: {} } as Partial) : {}), }; - // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both - // are runtime-compatible with v2's. Cast at this single construction - // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. - const bridge = new AppBridge( - client as unknown as ConstructorParameters[0], - HOST_INFO as unknown as ConstructorParameters[1], - hostCapabilities, - { - hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), - }, - ); + const bridge = new AppBridge(client, HOST_INFO, hostCapabilities, { + hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), + }); // The double-iframe proxy posts `sandboxready` once it can receive content. // Read the tool's UI resource and hand its HTML (plus any sandbox/permission diff --git a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts index e7907926..5cfcf50b 100644 --- a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts +++ b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts @@ -21,7 +21,7 @@ export const APP_ELICITATION_TIMEOUT_MS = 10 * 60 * 1000; * This is ext-apps' own `AppBridge.requestElicitation` from * modelcontextprotocol/ext-apps#733 — same method, same params, same result — * implemented against the bridge's generic `request()` because the released - * package (1.7.5) predates that PR. Replace the body with a call to + * package (2.0.0) predates that PR. Replace the body with a call to * `bridge.requestElicitation(params)` once a release containing #733 ships; * nothing on the wire changes when that happens. * @@ -37,23 +37,20 @@ export async function requestAppElicitation( // Fail closed on the app's own advertisement rather than discovering it as a // "-32601 method not found" ten minutes later: an app that never registered // an elicitation handler is a fallback case, not an error case. - // NOT `bridge.getAppCapabilities()` directly: ext-apps 1.7.5 strips the + // NOT `bridge.getAppCapabilities()` directly: ext-apps 2.0.0 strips the // `elicitation` key when it parses `ui/initialize`. See appCapabilities.ts. if (!appAdvertisesElicitation(bridge)) { throw new Error("App does not support elicitation"); } - // ext-apps 1.7.5's send union (`AppRequest`) has no `ElicitRequest` member — - // that is precisely what #733 adds — so TypeScript sees no overlap with the - // existing members and a single `as` is rejected. The double cast is the - // documented-gap case: the runtime is a plain JSON-RPC send of the standard - // method with its standard params, verified against the app-side handler in - // the fixture and the bridge tests. Confined to this one line and removed - // with the ext-apps bump, when `bridge.requestElicitation(params)` replaces it. - const request = { - method: "elicitation/create", - params, - } as unknown as Parameters[0]; - return (await bridge.request(request, ElicitResultSchema, { + // Since ext-apps 2.0.0 `AppBridge` extends the SDK v2 `Protocol`, whose + // `request()` takes the standard `Request` shape — `ElicitRequest` is one — + // so the send needs no cast (the 1.x peer's `AppRequest` union had no + // `ElicitRequest` member, which is what forced a double cast here; #1745). + // The runtime is a plain JSON-RPC send of the standard method with its + // standard params, verified against the app-side handler in the fixture and + // the bridge tests. + const request: ElicitRequest = { method: "elicitation/create", params }; + return await bridge.request(request, ElicitResultSchema, { timeout: timeoutMs, - })) as ElicitResult; + }); } diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index f24e3692..3f6c5793 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { act, renderHook } from "@testing-library/react"; +import { Client } from "@modelcontextprotocol/client"; import type { ClientCapabilities, Implementation, @@ -422,7 +423,9 @@ describe("useInspectorClient", () => { const { result, rerender } = renderHook(() => useInspectorClient(client)); expect(result.current.appRendererClient).toBeNull(); - const sentinel = { iam: "renderer" }; + // A real (unconnected) SDK client: `AppRendererClient` is the SDK `Client` + // itself since ext-apps 2.0.0 (#1745), and this hook only relays it. + const sentinel = new Client({ name: "renderer-sentinel", version: "0" }); client.setAppRendererClient(sentinel); rerender(); expect(result.current.appRendererClient).toBe(sentinel); diff --git a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts index e185daa5..ef486658 100644 --- a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts +++ b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts @@ -11,15 +11,19 @@ import { } from "@inspector/core/mcp/extensions.js"; /** - * Drift guard for the MCP Apps UI advertisement (#1740). `MCP_APP_MIME_TYPE` is - * hardcoded in core (ext-apps' constant lives on a `/server` subpath / an - * extensionless re-export that doesn't resolve cleanly under NodeNext in the - * browser build). A conforming server checks the client's advertised `mimeTypes` - * before serving an App, so if the hardcoded string ever drifts from ext-apps' - * real `RESOURCE_MIME_TYPE`, Apps silently stop working against strict servers. + * Drift guard for the MCP Apps UI advertisement (#1740). `UI_EXTENSION_KEY` is + * hardcoded in core (ext-apps' `EXTENSION_ID` still lives only on the `/server` + * subpath as of 2.0.0, which the browser build must not import). A conforming + * server keys its Apps lookup on the extension id and checks the client's + * advertised `mimeTypes` before serving an App, so a drifted string silently + * stops Apps working against strict servers. * - * This runs in the node integration project, where importing the real ext-apps - * value resolves — the one place the two can actually be compared. + * `MCP_APP_MIME_TYPE` is no longer a copy — since ext-apps 2.0.0 core re-exports + * `RESOURCE_MIME_TYPE` (#1745) — so its case here is a re-export check rather + * than a drift check; it stays so the advertisement is asserted end to end. + * + * This runs in the node integration project, where importing the `/server` + * value resolves — the one place the extension id can actually be compared. */ describe("MCP Apps UI extension constants (#1740)", () => { it("MCP_APP_MIME_TYPE matches ext-apps' RESOURCE_MIME_TYPE exactly", () => { diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index d0067baf..18ff9258 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -24,7 +24,11 @@ import { createNumberedPrompts, } from "@modelcontextprotocol/inspector-test-server"; import type { Tool } from "@modelcontextprotocol/client"; -import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; +import { + Client, + ProtocolError, + ProtocolErrorCode, +} from "@modelcontextprotocol/client"; const serverCommand = getTestMcpServerCommand(); @@ -114,26 +118,17 @@ describe("InspectorClient coverage backfill", () => { }); }); - describe("getAppRendererClient proxy", () => { - it("returns null before connect and a memoized proxy after connect", async () => { + describe("getAppRendererClient", () => { + it("returns null before connect and the SDK client after connect", async () => { client = stdioClient(); // Not connected yet → null. expect(client.getAppRendererClient()).toBeNull(); await client.connect(); - const proxy = client.getAppRendererClient(); - expect(proxy).not.toBeNull(); - // Second call returns the same memoized proxy. - expect(client.getAppRendererClient()).toBe(proxy); - - // Accessing setNotificationHandler returns the wrapped function (covers - // the prop === "setNotificationHandler" branch), and accessing another - // prop returns the underlying value (covers the fall-through return). - const wrapped = (proxy as unknown as { setNotificationHandler: unknown }) - .setNotificationHandler; - expect(typeof wrapped).toBe("function"); - const other = (proxy as unknown as { request: unknown }).request; - expect(typeof other).toBe("function"); + const appClient = client.getAppRendererClient(); + expect(appClient).toBeInstanceOf(Client); + // Same instance on every call — it is the client, not a wrapper (#1745). + expect(client.getAppRendererClient()).toBe(appClient); }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 55dd7dca..53c6642d 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -74,6 +74,7 @@ import type { ContentBlock, } from "@modelcontextprotocol/client"; import { + Client, LOG_LEVEL_META_KEY, RELATED_TASK_META_KEY, SdkError, @@ -5661,7 +5662,7 @@ describe("InspectorClient", () => { }); describe("getAppRendererClient", () => { - it("returns null before connect, and a cached proxy after connect", async () => { + it("returns null before connect, and the SDK client itself after connect", async () => { server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -5677,79 +5678,26 @@ describe("InspectorClient", () => { client = c; await c.connect(); - const proxy1 = c.getAppRendererClient(); - expect(proxy1).not.toBeNull(); - // Second call returns the cached proxy - expect(c.getAppRendererClient()).toBe(proxy1); - expect( - typeof (proxy1 as unknown as { setNotificationHandler?: unknown }) - .setNotificationHandler, - ).toBe("function"); - }); - - it("translates the ext-apps v1 schema-first setNotificationHandler call to v2's method-string form", async () => { - // Regression: `@modelcontextprotocol/ext-apps` (SDK v1 peer) subscribes - // with `setNotificationHandler(NotificationSchema, handler)`. On SDK v2 - // that throws "'[object Object]' is not a spec notification method", - // breaking App rendering at connect. The proxy must translate the - // schema (whose `.shape.method.value` is the method literal) to the - // method string so the handler still fires on the real notification. - server = createTestServerHttp({ - serverInfo: createTestServerInfo(), - tools: [createAddToolTool()], - listChanged: { tools: true }, - }); - await server.start(); - const c = new InspectorClient( - { type: "streamable-http", url: server.url }, - { environment: { transport: createTransportNode } }, - ); - client = c; - await c.connect(); - - const proxy = c.getAppRendererClient() as unknown as { - setNotificationHandler: ( - schema: unknown, - handler: () => void, - ) => void; - }; - // A v1-style Zod notification schema, as ext-apps passes it (NOT a - // string): its `.shape.method.value` carries the method literal. - const v1StyleSchema = { - shape: { method: { value: "notifications/tools/list_changed" } }, - }; - let handlerFired = false; + const appClient = c.getAppRendererClient(); + expect(appClient).not.toBeNull(); + // The same instance on every call — no per-call wrapper. ext-apps 2.x + // peers on SDK v2 and its `AppBridge` registers list-changed forwarding + // with `setNotificationHandler("notifications/…", handler)`, so the + // v1-peer translation proxy is gone (#1745). + expect(c.getAppRendererClient()).toBe(appClient); + expect(appClient).toBeInstanceOf(Client); + // The string-first registration the bridge performs must land on it + // directly and fire on the real notification. expect(() => - proxy.setNotificationHandler(v1StyleSchema, () => { - handlerFired = true; - }), - ).not.toThrow(); - - // The registration must land under the extracted method string: trigger - // a real tools/list_changed and confirm the schema-first handler runs. - const addToolTool = await getTool(c, "add_tool"); - await c.callTool(addToolTool, { - name: "added_via_schema_first", - description: "added at runtime", - }); - await vi.waitFor(() => expect(handlerFired).toBe(true), { - timeout: 5000, - }); - - // A native string-first call (ours) passes through unchanged. - expect(() => - proxy.setNotificationHandler( + appClient?.setNotificationHandler( "notifications/prompts/list_changed", () => {}, ), ).not.toThrow(); - // An unrecognized first arg (no `.shape.method.value`) can't be - // translated and falls through to the SDK, which rejects it clearly — - // we don't silently swallow a genuinely-malformed registration. - expect(() => - proxy.setNotificationHandler({} as unknown, () => {}), - ).toThrow(/not a spec notification method/); + // Disconnecting withdraws it again. + await c.disconnect(); + expect(c.getAppRendererClient()).toBeNull(); }); }); diff --git a/core/mcp/apps.ts b/core/mcp/apps.ts index 366e5a21..024cd490 100644 --- a/core/mcp/apps.ts +++ b/core/mcp/apps.ts @@ -1,4 +1,11 @@ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + McpUiResourceCsp, + McpUiResourceMeta, + McpUiResourcePermissions, + McpUiToolMeta, + McpUiToolVisibility, +} from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ReadResourceResult, Tool } from "@modelcontextprotocol/client"; /** @@ -19,13 +26,7 @@ import type { ReadResourceResult, Tool } from "@modelcontextprotocol/client"; * `@inspector/core`. */ export function getAppResourceUri(tool: Tool): string | undefined { - // `@modelcontextprotocol/ext-apps` still peers on SDK v1, so its - // `getToolUiResourceUri` param is typed as the v1 `Tool`. The runtime shape - // this reads (`_meta.ui.resourceUri`) is identical under v2, so cast at this - // single boundary. TODO: drop the cast when ext-apps#702 ships a v2 peer. - return getToolUiResourceUri( - tool as Parameters[0], - ); + return getToolUiResourceUri(tool); } /** @@ -53,10 +54,10 @@ export interface AppInfo { hasApp: boolean; toolName: string; resourceUri?: string; - visibility?: readonly string[]; + visibility?: readonly McpUiToolVisibility[]; /** 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>; + csp?: Readonly; + permissions?: Readonly; domain?: string; prefersBorder?: boolean; resourceMimeType?: string; @@ -65,37 +66,26 @@ export interface AppInfo { 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. + * Reads a carrier's `_meta.ui` as one of ext-apps' named `_meta.ui` shapes — + * `McpUiToolMeta` for a tool, `McpUiResourceMeta` for a UI resource — 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. * - * TODO: switch to the named `McpUiToolMeta` / `McpUiResourceMeta` types once - * upstream fixes the extensionless re-export so they resolve under NodeNext. + * The narrowing is structural, not validated: the fields pass through verbatim + * into {@link AppInfo}, and a caller that needs strict typing can parse them + * against the published Zod schemas (`McpUiToolMetaSchema` / + * `McpUiResourceMetaSchema`). Typed with the package's own names since ext-apps + * 2.0.0, whose declarations resolve under NodeNext (ext-apps#705) — the + * structural stand-in that preceded it is gone (#1745). */ -interface UiMetaShape { - visibility?: readonly string[]; - csp?: Record; - permissions?: Record; - domain?: string; - prefersBorder?: boolean; -} - -/** - * 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 { +function readUiMeta( + carrier: unknown, +): T | undefined { const ui = carrier !== null && typeof carrier === "object" ? (carrier as WithUiMeta)._meta?.ui : undefined; - return ui !== null && typeof ui === "object" - ? (ui as UiMetaShape) - : undefined; + return ui !== null && typeof ui === "object" ? (ui as T) : undefined; } /** @@ -149,14 +139,16 @@ export function extractAppInfo( if (resourceUri === undefined) { return { hasApp: false, toolName: tool.name }; } - const toolUi = readUiMeta(tool); + const toolUi = readUiMeta(tool); const content = resource && findResourceContent(resource, resourceUri); // 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); + const resourceUi = + readUiMeta(content) ?? + readUiMeta(resource); return { hasApp: true, toolName: tool.name, diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index e3c32f08..140d6323 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -1,4 +1,5 @@ import type { ClientCapabilities } from "@modelcontextprotocol/client"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js"; /** @@ -13,21 +14,24 @@ export const EMA_EXTENSION_KEY = /** * Extension identifier for the MCP Apps UI extension (SEP-ext-apps). Mirrors * `EXTENSION_ID` from `@modelcontextprotocol/ext-apps`. Hardcoded rather than - * imported: that constant lives on the package's `/server` subpath, which would - * pull server-only code into the browser bundle. The Inspector always renders - * MCP Apps, so this is advertised by default (#1740). + * imported: as of ext-apps 2.0.0 that constant is still exported only from the + * package's `/server` subpath, which would pull server-only code (and the + * optional `@modelcontextprotocol/server` peer) into the browser bundle. The + * node integration test `extensions-mimetype.test.ts` pins the two together. + * The Inspector always renders MCP Apps, so this is advertised by default + * (#1740). */ export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui"; /** - * The MCP Apps UI resource MIME type the Inspector renders. Mirrors - * `RESOURCE_MIME_TYPE` from `@modelcontextprotocol/ext-apps`; a server checks - * for it in the client's advertised `io.modelcontextprotocol/ui` `mimeTypes` to - * decide whether to serve an App. Hardcoded (stable spec string) because - * ext-apps re-exports it through an extensionless path that doesn't resolve - * cleanly under NodeNext — see the same note in `core/mcp/apps.ts`. + * The MCP Apps UI resource MIME type the Inspector renders. A server checks for + * it in the client's advertised `io.modelcontextprotocol/ui` `mimeTypes` to + * decide whether to serve an App. Re-exported from ext-apps' `/app-bridge` + * subpath — the one the Inspector already imports everywhere — rather than + * restated: the extensionless re-export that once kept this a hardcoded copy + * was fixed upstream (ext-apps#705) and shipped in 2.0.0 (#1745). */ -export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; +export const MCP_APP_MIME_TYPE = RESOURCE_MIME_TYPE; /** * The value the client stamps for each advertised extension. The wire shape is diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 29456ff4..91cc7ff8 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -366,24 +366,6 @@ async function closeSubscriptionBestEffort( } } -/** - * Extract the method literal from an MCP notification Zod schema (e.g. - * `ToolListChangedNotificationSchema`), or `undefined` if the shape isn't - * recognized. Used by the App-renderer client proxy to translate the SDK-v1 - * schema-first `setNotificationHandler` API — which `@modelcontextprotocol/ext-apps` - * still uses — into SDK v2's method-string form. Reads the `method` literal off - * the notification schema's `shape` (the shape both the v1 SDK and v2 core - * schemas expose). - */ -function notificationMethodFromSchema(schema: unknown): string | undefined { - if (schema !== null && typeof schema === "object") { - const literal = (schema as { shape?: { method?: { value?: unknown } } }) - .shape?.method?.value; - if (typeof literal === "string") return literal; - } - return undefined; -} - /** * The descriptor for a single tools/call, threaded through the retry loop and * each attempt. Bundled into one object so `callToolWithRetries`/`attemptToolCall` @@ -488,7 +470,6 @@ export class InspectorClient extends InspectorClientEventTarget { */ private static readonly MRTR_MAX_ROUNDS = 10; private client: Client | null = null; - private appRendererClientProxy: AppRendererClient | null = null; // Lazily-built validator used only on the skipOutputValidation path to detect // (non-fatally) when a delivered result violates the tool's outputSchema. private outputValidator: AjvJsonSchemaValidator | null = null; @@ -1017,7 +998,6 @@ export class InspectorClient extends InspectorClientEventTarget { this.rootsListChangedCapabilityAdvertised = capabilities.roots?.listChanged === true; - this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, version: corePackageJson.version, @@ -2612,7 +2592,6 @@ export class InspectorClient extends InspectorClientEventTarget { this.activeToolCallAbortController?.abort("Disconnected"); this.activeToolCallAbortController = undefined; this.clearReceiverTasks(); - this.appRendererClientProxy = null; this.capabilities = undefined; this.serverInfo = undefined; this.instructions = undefined; @@ -2644,44 +2623,19 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Returns a client proxy for use by AppRenderer / @mcp-ui. Delegates to the - * internal MCP Client. Returns null when not connected. Use this instead of - * accessing the raw client so behavior can be adapted here later if needed. + * The SDK client for the MCP Apps host bridge, or null when not connected. + * + * ext-apps' `AppBridge` (2.0.0+, an SDK v2 peer) takes the v2 `Client` + * directly and registers its list-changed forwarding on it in the + * method-string form, so this hands out the real client. The Proxy that + * translated the 1.x peer's schema-first `setNotificationHandler(Schema, + * handler)` into that form is gone with it (#1745). Kept as the seam rather + * than exposing the field so the connected-status gate lives in one place + * and hooks, fakes and tests depend on the protocol, not the class. */ getAppRendererClient(): AppRendererClient | null { if (!this.client || this.status !== "connected") return null; - if (this.appRendererClientProxy !== null) - return this.appRendererClientProxy; - const target = this.client; - this.appRendererClientProxy = new Proxy(this.client, { - get(proxyTarget, prop, receiver) { - const value = Reflect.get(proxyTarget, prop, receiver); - if (prop === "setNotificationHandler" && typeof value === "function") { - return (schemaOrMethod: unknown, ...rest: unknown[]) => { - // `@modelcontextprotocol/ext-apps` still peers on SDK v1 and - // subscribes to list-changed notifications with the v1 schema-first - // API `setNotificationHandler(NotificationSchema, handler)`. SDK v2 - // requires a method STRING as the first argument and throws - // "'[object Object]' is not a spec notification method" on a schema — - // which broke App rendering during the initial connect handshake. - // Translate a schema-first call to the method-string form; native - // string-first calls (ours) pass through untouched. Remove when - // ext-apps#702 ships a v2 peer. - const method = - typeof schemaOrMethod === "string" - ? schemaOrMethod - : (notificationMethodFromSchema(schemaOrMethod) ?? - schemaOrMethod); - return (value as (...a: unknown[]) => unknown).apply(target, [ - method, - ...rest, - ]); - }; - } - return value; - }, - }) as AppRendererClient; - return this.appRendererClientProxy; + return this.client; } /** diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 2da4c4e6..b5f7436e 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -25,6 +25,7 @@ import type { } from "./types.js"; import type { CacheMode, + Client, ClientCapabilities, DiscoverResult, Implementation, @@ -47,11 +48,13 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; /** - * Opaque type representing the AppRendererClient surface used by @mcp-ui. - * v1.5 aliases this to the SDK `Client` type; v2 leaves it opaque until the - * real InspectorClient (or a focused App-renderer port) lands. + * The SDK `Client` the MCP Apps host bridge is built over. ext-apps' + * `AppBridge` takes it directly and forwards a running app's + * tools/resources/prompts calls (and list-changed notifications) through it. + * Since ext-apps 2.0.0 peers on SDK v2 this is the real client with nothing in + * between — the v1-peer translation proxy it used to name is gone (#1745). */ -export type AppRendererClient = unknown; +export type AppRendererClient = Client; /** * The contract every state manager and hook depends on. Anything that holds diff --git a/docs/test-servers.md b/docs/test-servers.md index 7ba592c9..b0c33587 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -243,7 +243,7 @@ Only the **web** client advertises the nested client-side setting, and only beca Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute `ui://` URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise `elicitation`, a request that times out, and any result that is not a valid `ElicitResult` for the requested schema. An explicit `decline` or `cancel` is **not** a fallback — it is a completed elicitation and goes back to the server as-is. -> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. +> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (2.0.0) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. ## MRTR