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:
parent
c9e37204de
commit
54344552b7
1 changed files with 749 additions and 103 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue