diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..a64f001d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2 +} diff --git a/package.json b/package.json index bfb91c27..2c981014 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "express": "^5.1.0", "husky": "^9.1.7", "nodemon": "^3.1.0", - "prettier": "^3.6.2", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", @@ -75,6 +74,7 @@ "zod": "^3.25.0 || ^4.0.0" }, "dependencies": { + "prettier": "^3.6.2", "@modelcontextprotocol/sdk": "^1.24.3", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index dc559144..c82f8b14 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -2,7 +2,16 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + EmptyResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + PromptListChangedNotificationSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; import { App } from "./app"; import { AppBridge, type McpUiHostCapabilities } from "./app-bridge"; @@ -508,4 +517,193 @@ describe("App <-> AppBridge integration", () => { expect(result).toEqual({}); }); }); + + describe("AppBridge without MCP client (manual handlers)", () => { + let app: App; + let bridge: AppBridge; + let appTransport: InMemoryTransport; + let bridgeTransport: InMemoryTransport; + + beforeEach(() => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + app = new App(testAppInfo, {}, { autoResize: false }); + // Pass null instead of a client - manual handler registration + bridge = new AppBridge(null, testHostInfo, testHostCapabilities); + }); + + afterEach(async () => { + await appTransport.close(); + await bridgeTransport.close(); + }); + + it("connect() works without client", async () => { + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // Initialization should still work + const hostInfo = app.getHostVersion(); + expect(hostInfo).toEqual(testHostInfo); + }); + + it("oncalltool setter registers handler for tools/call requests", async () => { + const toolCall = { name: "test-tool", arguments: { arg: "value" } }; + const resultContent = [{ type: "text" as const, text: "result" }]; + const receivedCalls: unknown[] = []; + + bridge.oncalltool = async (params) => { + receivedCalls.push(params); + return { content: resultContent }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // App calls a tool via callServerTool + const result = await app.callServerTool(toolCall); + + expect(receivedCalls).toHaveLength(1); + expect(receivedCalls[0]).toMatchObject(toolCall); + expect(result.content).toEqual(resultContent); + }); + + it("onlistresources setter registers handler for resources/list requests", async () => { + const requestParams = {}; + const resources = [{ uri: "test://resource", name: "Test" }]; + const receivedRequests: unknown[] = []; + + bridge.onlistresources = async (params) => { + receivedRequests.push(params); + return { resources }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // App sends resources/list request via the protocol's request method + const result = await app.request( + { method: "resources/list", params: requestParams }, + ListResourcesResultSchema, + ); + + expect(receivedRequests).toHaveLength(1); + expect(receivedRequests[0]).toMatchObject(requestParams); + expect(result.resources).toEqual(resources); + }); + + it("onreadresource setter registers handler for resources/read requests", async () => { + const requestParams = { uri: "test://resource" }; + const contents = [{ uri: "test://resource", text: "content" }]; + const receivedRequests: unknown[] = []; + + bridge.onreadresource = async (params) => { + receivedRequests.push(params); + return { contents: [{ uri: params.uri, text: "content" }] }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const result = await app.request( + { method: "resources/read", params: requestParams }, + ReadResourceResultSchema, + ); + + expect(receivedRequests).toHaveLength(1); + expect(receivedRequests[0]).toMatchObject(requestParams); + expect(result.contents).toEqual(contents); + }); + + it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => { + const requestParams = {}; + const resourceTemplates = [ + { uriTemplate: "test://{id}", name: "Test Template" }, + ]; + const receivedRequests: unknown[] = []; + + bridge.onlistresourcetemplates = async (params) => { + receivedRequests.push(params); + return { resourceTemplates }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const result = await app.request( + { method: "resources/templates/list", params: requestParams }, + ListResourceTemplatesResultSchema, + ); + + expect(receivedRequests).toHaveLength(1); + expect(receivedRequests[0]).toMatchObject(requestParams); + expect(result.resourceTemplates).toEqual(resourceTemplates); + }); + + it("onlistprompts setter registers handler for prompts/list requests", async () => { + const requestParams = {}; + const prompts = [{ name: "test-prompt" }]; + const receivedRequests: unknown[] = []; + + bridge.onlistprompts = async (params) => { + receivedRequests.push(params); + return { prompts }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const result = await app.request( + { method: "prompts/list", params: requestParams }, + ListPromptsResultSchema, + ); + + expect(receivedRequests).toHaveLength(1); + expect(receivedRequests[0]).toMatchObject(requestParams); + expect(result.prompts).toEqual(prompts); + }); + + it("sendToolListChanged sends notification to app", async () => { + const receivedNotifications: unknown[] = []; + app.setNotificationHandler(ToolListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + bridge.sendToolListChanged(); + await flush(); + + expect(receivedNotifications).toHaveLength(1); + }); + + it("sendResourceListChanged sends notification to app", async () => { + const receivedNotifications: unknown[] = []; + app.setNotificationHandler(ResourceListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + bridge.sendResourceListChanged(); + await flush(); + + expect(receivedNotifications).toHaveLength(1); + }); + + it("sendPromptListChanged sends notification to app", async () => { + const receivedNotifications: unknown[] = []; + app.setNotificationHandler(PromptListChangedNotificationSchema, (n) => { + receivedNotifications.push(n.params); + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + bridge.sendPromptListChanged(); + await flush(); + + expect(receivedNotifications).toHaveLength(1); + }); + }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 4979ac6f..bed0a75d 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,28 +1,36 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import * as z from "zod/v4/core"; - import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { + CallToolRequest, CallToolRequestSchema, + CallToolResult, CallToolResultSchema, Implementation, + ListPromptsRequest, ListPromptsRequestSchema, + ListPromptsResult, ListPromptsResultSchema, + ListResourcesRequest, ListResourcesRequestSchema, + ListResourcesResult, ListResourcesResultSchema, + ListResourceTemplatesRequest, ListResourceTemplatesRequestSchema, + ListResourceTemplatesResult, ListResourceTemplatesResultSchema, LoggingMessageNotification, LoggingMessageNotificationSchema, - Notification, PingRequest, PingRequestSchema, + PromptListChangedNotification, PromptListChangedNotificationSchema, + ReadResourceRequest, ReadResourceRequestSchema, + ReadResourceResult, ReadResourceResultSchema, - Request, + ResourceListChangedNotification, ResourceListChangedNotificationSchema, - Result, + ToolListChangedNotification, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; import { @@ -32,6 +40,9 @@ import { } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { + type AppNotification, + type AppRequest, + type AppResult, type McpUiSandboxResourceReadyNotification, type McpUiSizeChangedNotification, type McpUiToolCancelledNotification, @@ -55,6 +66,7 @@ import { McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, McpUiResourceTeardownRequest, + McpUiResourceTeardownResult, McpUiResourceTeardownResultSchema, McpUiSandboxProxyReadyNotification, McpUiSandboxProxyReadyNotificationSchema, @@ -153,7 +165,11 @@ type RequestHandlerExtra = Parameters< * await bridge.connect(transport); * ``` */ -export class AppBridge extends Protocol { +export class AppBridge extends Protocol< + AppRequest, + AppNotification, + AppResult +> { private _appCapabilities?: McpUiAppCapabilities; private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; @@ -161,12 +177,15 @@ export class AppBridge extends Protocol { /** * Create a new AppBridge instance. * - * @param _client - MCP client connected to the server (for proxying requests) + * @param _client - MCP client connected to the server, or `null`. When provided, + * {@link connect} will automatically set up forwarding of MCP requests/notifications + * between the Guest UI and the server. When `null`, you must register handlers + * manually using the `oncalltool`, `onlistresources`, etc. setters. * @param _hostInfo - Host application identification (name and version) * @param _capabilities - Features and capabilities the host supports * @param options - Configuration options (inherited from Protocol) * - * @example + * @example With MCP client (automatic forwarding) * ```typescript * const bridge = new AppBridge( * mcpClient, @@ -174,9 +193,19 @@ export class AppBridge extends Protocol { * { openLinks: {}, serverTools: {}, logging: {} } * ); * ``` + * + * @example Without MCP client (manual handlers) + * ```typescript + * const bridge = new AppBridge( + * null, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} } + * ); + * bridge.oncalltool = async (params, extra) => { ... }; + * ``` */ constructor( - private _client: Client, + private _client: Client | null, private _hostInfo: Implementation, private _capabilities: McpUiHostCapabilities, options?: HostOptions, @@ -503,11 +532,285 @@ export class AppBridge extends Protocol { ); } + /** + * Register a handler for tool call requests from the Guest UI. + * + * The Guest UI sends `tools/call` requests to execute MCP server tools. This + * handler allows the host to intercept and process these requests, typically + * by forwarding them to the MCP server. + * + * @param callback - Handler that receives tool call params and returns a + * {@link CallToolResult} + * @param callback.params - Tool call parameters (name and arguments) + * @param callback.extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.oncalltool = async ({ name, arguments: args }, extra) => { + * return mcpClient.request( + * { method: "tools/call", params: { name, arguments: args } }, + * CallToolResultSchema, + * { signal: extra.signal } + * ); + * }; + * ``` + * + * @see {@link CallToolRequest} for the request type + * @see {@link CallToolResult} for the result type + */ + set oncalltool( + callback: ( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + return callback(request.params, extra); + }); + } + + /** + * Notify the Guest UI that the MCP server's tool list has changed. + * + * The host sends `notifications/tools/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI + * to refresh its tool cache or UI accordingly. + * + * @param params - Optional notification params (typically empty) + * + * @example + * ```typescript + * // In your MCP client notification handler: + * mcpClient.setNotificationHandler(ToolListChangedNotificationSchema, () => { + * bridge.sendToolListChanged(); + * }); + * ``` + * + * @see {@link ToolListChangedNotification} for the notification type + */ + sendToolListChanged(params: ToolListChangedNotification["params"] = {}) { + return this.notification({ + method: "notifications/tools/list_changed" as const, + params, + }); + } + + /** + * Register a handler for list resources requests from the Guest UI. + * + * The Guest UI sends `resources/list` requests to enumerate available MCP + * resources. This handler allows the host to intercept and process these + * requests, typically by forwarding them to the MCP server. + * + * @param callback - Handler that receives list params and returns a + * {@link ListResourcesResult} + * @param callback.params - Request params (may include cursor for pagination) + * @param callback.extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.onlistresources = async (params, extra) => { + * return mcpClient.request( + * { method: "resources/list", params }, + * ListResourcesResultSchema, + * { signal: extra.signal } + * ); + * }; + * ``` + * + * @see {@link ListResourcesRequest} for the request type + * @see {@link ListResourcesResult} for the result type + */ + set onlistresources( + callback: ( + params: ListResourcesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + ListResourcesRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } + + /** + * Register a handler for list resource templates requests from the Guest UI. + * + * The Guest UI sends `resources/templates/list` requests to enumerate available + * MCP resource templates. This handler allows the host to intercept and process + * these requests, typically by forwarding them to the MCP server. + * + * @param callback - Handler that receives list params and returns a + * {@link ListResourceTemplatesResult} + * @param callback.params - Request params (may include cursor for pagination) + * @param callback.extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.onlistresourcetemplates = async (params, extra) => { + * return mcpClient.request( + * { method: "resources/templates/list", params }, + * ListResourceTemplatesResultSchema, + * { signal: extra.signal } + * ); + * }; + * ``` + * + * @see {@link ListResourceTemplatesRequest} for the request type + * @see {@link ListResourceTemplatesResult} for the result type + */ + set onlistresourcetemplates( + callback: ( + params: ListResourceTemplatesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + ListResourceTemplatesRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } + + /** + * Register a handler for read resource requests from the Guest UI. + * + * The Guest UI sends `resources/read` requests to retrieve the contents of an + * MCP resource. This handler allows the host to intercept and process these + * requests, typically by forwarding them to the MCP server. + * + * @param callback - Handler that receives read params and returns a + * {@link ReadResourceResult} + * @param callback.params - Read parameters including the resource URI + * @param callback.extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.onreadresource = async ({ uri }, extra) => { + * return mcpClient.request( + * { method: "resources/read", params: { uri } }, + * ReadResourceResultSchema, + * { signal: extra.signal } + * ); + * }; + * ``` + * + * @see {@link ReadResourceRequest} for the request type + * @see {@link ReadResourceResult} for the result type + */ + set onreadresource( + callback: ( + params: ReadResourceRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + ReadResourceRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } + + /** + * Notify the Guest UI that the MCP server's resource list has changed. + * + * The host sends `notifications/resources/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI + * to refresh its resource cache or UI accordingly. + * + * @param params - Optional notification params (typically empty) + * + * @example + * ```typescript + * // In your MCP client notification handler: + * mcpClient.setNotificationHandler(ResourceListChangedNotificationSchema, () => { + * bridge.sendResourceListChanged(); + * }); + * ``` + * + * @see {@link ResourceListChangedNotification} for the notification type + */ + sendResourceListChanged( + params: ResourceListChangedNotification["params"] = {}, + ) { + return this.notification({ + method: "notifications/resources/list_changed" as const, + params, + }); + } + + /** + * Register a handler for list prompts requests from the Guest UI. + * + * The Guest UI sends `prompts/list` requests to enumerate available MCP + * prompts. This handler allows the host to intercept and process these + * requests, typically by forwarding them to the MCP server. + * + * @param callback - Handler that receives list params and returns a + * {@link ListPromptsResult} + * @param callback.params - Request params (may include cursor for pagination) + * @param callback.extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.onlistprompts = async (params, extra) => { + * return mcpClient.request( + * { method: "prompts/list", params }, + * ListPromptsResultSchema, + * { signal: extra.signal } + * ); + * }; + * ``` + * + * @see {@link ListPromptsRequest} for the request type + * @see {@link ListPromptsResult} for the result type + */ + set onlistprompts( + callback: ( + params: ListPromptsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler(ListPromptsRequestSchema, async (request, extra) => { + return callback(request.params, extra); + }); + } + + /** + * Notify the Guest UI that the MCP server's prompt list has changed. + * + * The host sends `notifications/prompts/list_changed` to the Guest UI when it + * receives this notification from the MCP server. This allows the Guest UI + * to refresh its prompt cache or UI accordingly. + * + * @param params - Optional notification params (typically empty) + * + * @example + * ```typescript + * // In your MCP client notification handler: + * mcpClient.setNotificationHandler(PromptListChangedNotificationSchema, () => { + * bridge.sendPromptListChanged(); + * }); + * ``` + * + * @see {@link PromptListChangedNotification} for the notification type + */ + sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) { + return this.notification({ + method: "notifications/prompts/list_changed" as const, + params, + }); + } + /** * Verify that the guest supports the capability required for the given request method. * @internal */ - assertCapabilityForMethod(method: Request["method"]): void { + assertCapabilityForMethod(method: AppRequest["method"]): void { // TODO } @@ -515,7 +818,7 @@ export class AppBridge extends Protocol { * Verify that a request handler is registered and supported for the given method. * @internal */ - assertRequestHandlerCapability(method: Request["method"]): void { + assertRequestHandlerCapability(method: AppRequest["method"]): void { // TODO } @@ -523,7 +826,7 @@ export class AppBridge extends Protocol { * Verify that the host supports the capability required for the given notification method. * @internal */ - assertNotificationCapability(method: Notification["method"]): void { + assertNotificationCapability(method: AppNotification["method"]): void { // TODO } @@ -638,10 +941,10 @@ export class AppBridge extends Protocol { sendHostContextChange( params: McpUiHostContextChangedNotification["params"], ): Promise | void { - return this.notification(({ - method: "ui/notifications/host-context-changed", + return this.notification({ + method: "ui/notifications/host-context-changed" as const, params, - }) as Notification); + }); } /** @@ -667,8 +970,8 @@ export class AppBridge extends Protocol { * @see {@link sendToolResult} for sending results after execution */ sendToolInput(params: McpUiToolInputNotification["params"]) { - return this.notification({ - method: "ui/notifications/tool-input", + return this.notification({ + method: "ui/notifications/tool-input" as const, params, }); } @@ -701,8 +1004,8 @@ export class AppBridge extends Protocol { * @see {@link sendToolInput} for sending complete arguments */ sendToolInputPartial(params: McpUiToolInputPartialNotification["params"]) { - return this.notification({ - method: "ui/notifications/tool-input-partial", + return this.notification({ + method: "ui/notifications/tool-input-partial" as const, params, }); } @@ -732,8 +1035,8 @@ export class AppBridge extends Protocol { * @see {@link sendToolInput} for sending tool arguments before results */ sendToolResult(params: McpUiToolResultNotification["params"]) { - return this.notification({ - method: "ui/notifications/tool-result", + return this.notification({ + method: "ui/notifications/tool-result" as const, params, }); } @@ -769,8 +1072,8 @@ export class AppBridge extends Protocol { * @see {@link sendToolInput} for sending tool arguments */ sendToolCancelled(params: McpUiToolCancelledNotification["params"]) { - return this.notification({ - method: "ui/notifications/tool-cancelled", + return this.notification({ + method: "ui/notifications/tool-cancelled" as const, params, }); } @@ -793,8 +1096,8 @@ export class AppBridge extends Protocol { sendSandboxResourceReady( params: McpUiSandboxResourceReadyNotification["params"], ) { - return this.notification({ - method: "ui/notifications/sandbox-resource-ready", + return this.notification({ + method: "ui/notifications/sandbox-resource-ready" as const, params, }); } @@ -828,8 +1131,8 @@ export class AppBridge extends Protocol { options?: RequestOptions, ) { return this.request( - { - method: "ui/resource-teardown", + { + method: "ui/resource-teardown" as const, params, }, McpUiResourceTeardownResultSchema, @@ -837,39 +1140,19 @@ export class AppBridge extends Protocol { ); } - private forwardRequest< - Req extends z.$ZodObject<{ - method: z.$ZodLiteral; - }>, - Res extends z.$ZodObject<{}>, - >(requestSchema: Req, resultSchema: Res) { - this.setRequestHandler(requestSchema, async (request, extra) => { - console.log(`Forwarding request ${request.method} from MCP UI client`); - return this._client.request(request, resultSchema, { - signal: extra.signal, - }); - }); - } - private forwardNotification< - N extends z.$ZodObject<{ method: z.$ZodLiteral }>, - >(notificationSchema: N) { - this.setNotificationHandler(notificationSchema, async (notification) => { - console.log( - `Forwarding notification ${notification.method} from MCP UI client`, - ); - await this._client.notification(notification); - }); - } - /** - * Connect to the Guest UI via transport and set up message forwarding. + * Connect to the Guest UI via transport and optionally set up message forwarding. + * + * This method establishes the transport connection. If an MCP client was passed + * to the constructor, it also automatically sets up request/notification forwarding + * based on the MCP server's capabilities, proxying the following to the Guest UI: + * - Tools (tools/call, notifications/tools/list_changed) + * - Resources (resources/list, resources/read, resources/templates/list, notifications/resources/list_changed) + * - Prompts (prompts/list, notifications/prompts/list_changed) * - * This method establishes the transport connection and automatically sets up - * request/notification forwarding based on the MCP server's capabilities. - * It proxies the following server capabilities to the Guest UI: - * - Tools (tools/call, tools/list_changed) - * - Resources (resources/list, resources/read, resources/templates/list, resources/list_changed) - * - Prompts (prompts/list, prompts/list_changed) + * If no client was passed to the constructor, no automatic forwarding is set up + * and you must register handlers manually using the `oncalltool`, `onlistresources`, + * etc. setters. * * After calling connect, wait for the `oninitialized` callback before sending * tool input and other data to the Guest UI. @@ -877,12 +1160,12 @@ export class AppBridge extends Protocol { * @param transport - Transport layer (typically PostMessageTransport) * @returns Promise resolving when connection is established * - * @throws {Error} If server capabilities are not available. This occurs when - * connect() is called before the MCP client has completed its initialization - * with the server. Ensure `await client.connect()` completes before calling - * `bridge.connect()`. + * @throws {Error} If a client was passed but server capabilities are not available. + * This occurs when connect() is called before the MCP client has completed its + * initialization with the server. Ensure `await client.connect()` completes + * before calling `bridge.connect()`. * - * @example + * @example With MCP client (automatic forwarding) * ```typescript * const bridge = new AppBridge(mcpClient, hostInfo, capabilities); * const transport = new PostMessageTransport( @@ -897,38 +1180,86 @@ export class AppBridge extends Protocol { * * await bridge.connect(transport); * ``` + * + * @example Without MCP client (manual handlers) + * ```typescript + * const bridge = new AppBridge(null, hostInfo, capabilities); + * + * // Register handlers manually + * bridge.oncalltool = async (params, extra) => { + * // Custom tool call handling + * }; + * + * await bridge.connect(transport); + * ``` */ async connect(transport: Transport) { - // Forward core available MCP features - const serverCapabilities = this._client.getServerCapabilities(); - if (!serverCapabilities) { - throw new Error("Client server capabilities not available"); - } + if (this._client) { + // When a client was passed to the constructor, automatically forward + // MCP requests/notifications between the Guest UI and the server + const serverCapabilities = this._client.getServerCapabilities(); + if (!serverCapabilities) { + throw new Error("Client server capabilities not available"); + } - if (serverCapabilities.tools) { - this.forwardRequest(CallToolRequestSchema, CallToolResultSchema); - if (serverCapabilities.tools.listChanged) { - this.forwardNotification(ToolListChangedNotificationSchema); + if (serverCapabilities.tools) { + this.oncalltool = async (params, extra) => { + return this._client!.request( + { method: "tools/call", params }, + CallToolResultSchema, + { signal: extra.signal }, + ); + }; + if (serverCapabilities.tools.listChanged) { + this._client.setNotificationHandler( + ToolListChangedNotificationSchema, + (n) => this.sendToolListChanged(n.params), + ); + } } - } - if (serverCapabilities.resources) { - this.forwardRequest( - ListResourcesRequestSchema, - ListResourcesResultSchema, - ); - this.forwardRequest( - ListResourceTemplatesRequestSchema, - ListResourceTemplatesResultSchema, - ); - this.forwardRequest(ReadResourceRequestSchema, ReadResourceResultSchema); - if (serverCapabilities.resources.listChanged) { - this.forwardNotification(ResourceListChangedNotificationSchema); + if (serverCapabilities.resources) { + this.onlistresources = async (params, extra) => { + return this._client!.request( + { method: "resources/list", params }, + ListResourcesResultSchema, + { signal: extra.signal }, + ); + }; + this.onlistresourcetemplates = async (params, extra) => { + return this._client!.request( + { method: "resources/templates/list", params }, + ListResourceTemplatesResultSchema, + { signal: extra.signal }, + ); + }; + this.onreadresource = async (params, extra) => { + return this._client!.request( + { method: "resources/read", params }, + ReadResourceResultSchema, + { signal: extra.signal }, + ); + }; + if (serverCapabilities.resources.listChanged) { + this._client.setNotificationHandler( + ResourceListChangedNotificationSchema, + (n) => this.sendResourceListChanged(n.params), + ); + } } - } - if (serverCapabilities.prompts) { - this.forwardRequest(ListPromptsRequestSchema, ListPromptsResultSchema); - if (serverCapabilities.prompts.listChanged) { - this.forwardNotification(PromptListChangedNotificationSchema); + if (serverCapabilities.prompts) { + this.onlistprompts = async (params, extra) => { + return this._client!.request( + { method: "prompts/list", params }, + ListPromptsResultSchema, + { signal: extra.signal }, + ); + }; + if (serverCapabilities.prompts.listChanged) { + this._client.setNotificationHandler( + PromptListChangedNotificationSchema, + (n) => this.sendPromptListChanged(n.params), + ); + } } } diff --git a/src/app.ts b/src/app.ts index 51189b97..c4970fcd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,11 +13,9 @@ import { ListToolsRequest, ListToolsRequestSchema, LoggingMessageNotification, - Notification, PingRequestSchema, - Request, - Result, } from "@modelcontextprotocol/sdk/types.js"; +import { AppNotification, AppRequest, AppResult } from "./types"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -189,7 +187,7 @@ type RequestHandlerExtra = Parameters< * }); * ``` */ -export class App extends Protocol { +export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; @@ -642,7 +640,7 @@ export class App extends Protocol { * Verify that the host supports the capability required for the given request method. * @internal */ - assertCapabilityForMethod(method: Request["method"]): void { + assertCapabilityForMethod(method: AppRequest["method"]): void { // TODO } @@ -650,7 +648,7 @@ export class App extends Protocol { * Verify that the app declared the capability required for the given request method. * @internal */ - assertRequestHandlerCapability(method: Request["method"]): void { + assertRequestHandlerCapability(method: AppRequest["method"]): void { switch (method) { case "tools/call": case "tools/list": @@ -672,7 +670,7 @@ export class App extends Protocol { * Verify that the app supports the capability required for the given notification method. * @internal */ - assertNotificationCapability(method: Notification["method"]): void { + assertNotificationCapability(method: AppNotification["method"]): void { // TODO } diff --git a/src/generated/schema.json b/src/generated/schema.json index 4d4ae53b..f70b60f9 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -381,7 +381,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} } }, "required": ["method", "params"], @@ -667,7 +667,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} }, "McpUiInitializeRequest": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1135,7 +1135,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} } }, "required": [ diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 2fa4a13f..baff9658 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -337,7 +337,7 @@ export const McpUiToolResultNotificationSchema = z.object({ /** * @description Rich context about the host environment provided to Guest UIs. */ -export const McpUiHostContextSchema = z.object({ +export const McpUiHostContextSchema = z.looseObject({ /** @description Metadata of the tool call that instantiated this App. */ toolInfo: z .object({ diff --git a/src/spec.types.ts b/src/spec.types.ts index 84713847..01cc4713 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -184,6 +184,8 @@ export interface McpUiToolCancelledNotification { * @description Rich context about the host environment provided to Guest UIs. */ export interface McpUiHostContext { + /** @description Allow additional properties for forward compatibility. */ + [key: string]: unknown; /** @description Metadata of the tool call that instantiated this App. */ toolInfo?: { /** @description JSON-RPC id of the tools/call request. */ diff --git a/src/types.ts b/src/types.ts index 5ed81313..8544b27e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,27 @@ export { type McpUiResourceMeta, } from "./spec.types.js"; +// Import types needed for protocol type unions (not re-exported, just used internally) +import type { + McpUiInitializeRequest, + McpUiOpenLinkRequest, + McpUiMessageRequest, + McpUiResourceTeardownRequest, + McpUiHostContextChangedNotification, + McpUiToolInputNotification, + McpUiToolInputPartialNotification, + McpUiToolResultNotification, + McpUiToolCancelledNotification, + McpUiSandboxResourceReadyNotification, + McpUiInitializedNotification, + McpUiSizeChangedNotification, + McpUiSandboxProxyReadyNotification, + McpUiInitializeResult, + McpUiOpenLinkResult, + McpUiMessageResult, + McpUiResourceTeardownResult, +} from "./spec.types.js"; + // Re-export all schemas from generated/schema.ts (already PascalCase) export { McpUiThemeSchema, @@ -65,3 +86,92 @@ export { McpUiResourceCspSchema, McpUiResourceMetaSchema, } from "./generated/schema.js"; + +// Re-export SDK types used in protocol type unions +import { + CallToolRequest, + CallToolResult, + EmptyResult, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListToolsRequest, + ListToolsResult, + LoggingMessageNotification, + PingRequest, + PromptListChangedNotification, + ReadResourceRequest, + ReadResourceResult, + ResourceListChangedNotification, + ToolListChangedNotification, +} from "@modelcontextprotocol/sdk/types.js"; + +/** + * All request types in the MCP Apps protocol. + * + * Includes: + * - MCP UI requests (initialize, open-link, message, resource-teardown) + * - MCP server requests forwarded from the app (tools/call, resources/*, prompts/list) + * - Protocol requests (ping) + */ +export type AppRequest = + | McpUiInitializeRequest + | McpUiOpenLinkRequest + | McpUiMessageRequest + | McpUiResourceTeardownRequest + | CallToolRequest + | ListToolsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | ListPromptsRequest + | PingRequest; + +/** + * All notification types in the MCP Apps protocol. + * + * Host to app: + * - Tool lifecycle (input, input-partial, result, cancelled) + * - Host context changes + * - MCP list changes (tools, resources, prompts) + * - Sandbox resource ready + * + * App to host: + * - Initialized, size-changed, sandbox-proxy-ready + * - Logging messages + */ +export type AppNotification = + // Sent to app + | McpUiHostContextChangedNotification + | McpUiToolInputNotification + | McpUiToolInputPartialNotification + | McpUiToolResultNotification + | McpUiToolCancelledNotification + | McpUiSandboxResourceReadyNotification + | ToolListChangedNotification + | ResourceListChangedNotification + | PromptListChangedNotification + // Received from app + | McpUiInitializedNotification + | McpUiSizeChangedNotification + | McpUiSandboxProxyReadyNotification + | LoggingMessageNotification; + +/** + * All result types in the MCP Apps protocol. + */ +export type AppResult = + | McpUiInitializeResult + | McpUiOpenLinkResult + | McpUiMessageResult + | McpUiResourceTeardownResult + | CallToolResult + | ListToolsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | ListPromptsResult + | EmptyResult;