api: optional Client in AppBridge (allow custom forwarding), better protocol types (AppRequest, AppNotification, AppResult) (#146)

* Add setter-based handlers for MCP request/notification forwarding

- Add oncalltool, onlistresources, onlistresourcetemplates, onreadresource, onlistprompts setters for Guest UI → Host requests
- Add sendToolListChanged, sendResourceListChanged, sendPromptListChanged methods for Host → Guest UI notifications
- Refactor connect() to use new setters with inlined callbacks
- Update documentation to clarify optional client parameter behavior
- Remove unused forwardRequest/forwardNotification helper methods
- Add comprehensive tests for AppBridge without MCP client (manual handlers)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Potential fix for pull request finding 'Unused variable, import, function or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: move prettier to dependencies for git-based installs

* fix: add explicit prettierrc to prevent inheriting parent configs during git-based installs

* Apply suggestions from code review

Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>

* refactor(tests): factor out test values and add missing assertions

Address PR review feedback:
- Factor out test value objects (toolCall, resultContent, etc.) for clearer tests
- Add missing assertions on receivedRequests[0] for list handlers
- Improve test readability by defining expected values at the top of each test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: improve TypeDoc formatting and use `as const` for notification methods

- Use proper TypeDoc syntax with `@param callback.params` and `@param callback.extra`
- Add {@link ResultType} references for return types
- Replace `as Notification` with `as const` on method literals where possible
  (sendResourceListChanged, sendPromptListChanged)
- Simplify sendHostContextChange cast syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(types): use explicit type unions for AppBridge protocol types

- Add AppBridgeRequest, AppBridgeNotification, AppBridgeResult type unions
  that explicitly list all valid types for the protocol
- Replace generic SDK Request/Notification/Result with our specific types
- Remove all type casts by using `as const` on method literals
- Add index signature to McpUiHostContext for forward compatibility

This improves type safety and documents exactly what the AppBridge supports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: move AppRequest/AppNotification/AppResult to types.ts

Move protocol type unions from app-bridge.ts to types.ts where they
belong with other type definitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: regenerate schemas with clean npm install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
This commit is contained in:
Olivier Chafik 2025-12-13 21:07:13 +00:00 • committed by GitHub
commit 54344552b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 749 additions and 103 deletions

7
.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2
}

View file

@ -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"

View file

@ -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);
});
});
});

View file

@ -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<Request, Notification, Result> {
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<Request, Notification, Result> {
/**
* 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<Request, Notification, Result> {
* { 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<Request, Notification, Result> {
);
}
/**
* 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<CallToolResult>,
) {
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<ListResourcesResult>,
) {
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<ListResourceTemplatesResult>,
) {
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<ReadResourceResult>,
) {
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<ListPromptsResult>,
) {
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<Request, Notification, Result> {
* 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<Request, Notification, Result> {
* 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<Request, Notification, Result> {
sendHostContextChange(
params: McpUiHostContextChangedNotification["params"],
): Promise<void> | void {
return this.notification((<McpUiHostContextChangedNotification>{
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<Request, Notification, Result> {
* @see {@link sendToolResult} for sending results after execution
*/
sendToolInput(params: McpUiToolInputNotification["params"]) {
return this.notification(<McpUiToolInputNotification>{
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<Request, Notification, Result> {
* @see {@link sendToolInput} for sending complete arguments
*/
sendToolInputPartial(params: McpUiToolInputPartialNotification["params"]) {
return this.notification(<McpUiToolInputPartialNotification>{
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<Request, Notification, Result> {
* @see {@link sendToolInput} for sending tool arguments before results
*/
sendToolResult(params: McpUiToolResultNotification["params"]) {
return this.notification(<McpUiToolResultNotification>{
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<Request, Notification, Result> {
* @see {@link sendToolInput} for sending tool arguments
*/
sendToolCancelled(params: McpUiToolCancelledNotification["params"]) {
return this.notification(<McpUiToolCancelledNotification>{
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<Request, Notification, Result> {
sendSandboxResourceReady(
params: McpUiSandboxResourceReadyNotification["params"],
) {
return this.notification(<McpUiSandboxResourceReadyNotification>{
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<Request, Notification, Result> {
options?: RequestOptions,
) {
return this.request(
<McpUiResourceTeardownRequest>{
method: "ui/resource-teardown",
{
method: "ui/resource-teardown" as const,
params,
},
McpUiResourceTeardownResultSchema,
@ -837,39 +1140,19 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
);
}
private forwardRequest<
Req extends z.$ZodObject<{
method: z.$ZodLiteral<string>;
}>,
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<string> }>,
>(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 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)
* 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)
*
* 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<Request, Notification, Result> {
* @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<Request, Notification, Result> {
*
* 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),
);
}
}
}

View file

@ -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<Request, Notification, Result> {
export class App extends Protocol<AppRequest, AppNotification, AppResult> {
private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: Implementation;
private _hostContext?: McpUiHostContext;
@ -642,7 +640,7 @@ export class App extends Protocol<Request, Notification, Result> {
* 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<Request, Notification, Result> {
* 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<Request, Notification, Result> {
* 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
}

View file

@ -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": [

View file

@ -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({

View file

@ -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. */

View file

@ -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;