Skip to content

Commit 5434455

Browse files
ochafikclaudegithub-code-quality[bot]jonathanhefner
authored
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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * chore: regenerate schemas with clean npm install 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Jonathan Hefner <[email protected]>
1 parent c9e3720 commit 5434455

File tree

9 files changed

+749
-103
lines changed

9 files changed

+749
-103
lines changed

.prettierrc.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"semi": true,
3+
"trailingComma": "all",
4+
"singleQuote": false,
5+
"printWidth": 80,
6+
"tabWidth": 2
7+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"express": "^5.1.0",
6565
"husky": "^9.1.7",
6666
"nodemon": "^3.1.0",
67-
"prettier": "^3.6.2",
6867
"ts-to-zod": "^5.1.0",
6968
"tsx": "^4.21.0",
7069
"typedoc": "^0.28.14",
@@ -75,6 +74,7 @@
7574
"zod": "^3.25.0 || ^4.0.0"
7675
},
7776
"dependencies": {
77+
"prettier": "^3.6.2",
7878
"@modelcontextprotocol/sdk": "^1.24.3",
7979
"react": "^19.2.0",
8080
"react-dom": "^19.2.0"

src/app-bridge.test.ts

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
22
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
44
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
5-
import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js";
5+
import {
6+
EmptyResultSchema,
7+
ListPromptsResultSchema,
8+
ListResourcesResultSchema,
9+
ListResourceTemplatesResultSchema,
10+
PromptListChangedNotificationSchema,
11+
ReadResourceResultSchema,
12+
ResourceListChangedNotificationSchema,
13+
ToolListChangedNotificationSchema,
14+
} from "@modelcontextprotocol/sdk/types.js";
615

716
import { App } from "./app";
817
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
@@ -508,4 +517,193 @@ describe("App <-> AppBridge integration", () => {
508517
expect(result).toEqual({});
509518
});
510519
});
520+
521+
describe("AppBridge without MCP client (manual handlers)", () => {
522+
let app: App;
523+
let bridge: AppBridge;
524+
let appTransport: InMemoryTransport;
525+
let bridgeTransport: InMemoryTransport;
526+
527+
beforeEach(() => {
528+
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
529+
app = new App(testAppInfo, {}, { autoResize: false });
530+
// Pass null instead of a client - manual handler registration
531+
bridge = new AppBridge(null, testHostInfo, testHostCapabilities);
532+
});
533+
534+
afterEach(async () => {
535+
await appTransport.close();
536+
await bridgeTransport.close();
537+
});
538+
539+
it("connect() works without client", async () => {
540+
await bridge.connect(bridgeTransport);
541+
await app.connect(appTransport);
542+
543+
// Initialization should still work
544+
const hostInfo = app.getHostVersion();
545+
expect(hostInfo).toEqual(testHostInfo);
546+
});
547+
548+
it("oncalltool setter registers handler for tools/call requests", async () => {
549+
const toolCall = { name: "test-tool", arguments: { arg: "value" } };
550+
const resultContent = [{ type: "text" as const, text: "result" }];
551+
const receivedCalls: unknown[] = [];
552+
553+
bridge.oncalltool = async (params) => {
554+
receivedCalls.push(params);
555+
return { content: resultContent };
556+
};
557+
558+
await bridge.connect(bridgeTransport);
559+
await app.connect(appTransport);
560+
561+
// App calls a tool via callServerTool
562+
const result = await app.callServerTool(toolCall);
563+
564+
expect(receivedCalls).toHaveLength(1);
565+
expect(receivedCalls[0]).toMatchObject(toolCall);
566+
expect(result.content).toEqual(resultContent);
567+
});
568+
569+
it("onlistresources setter registers handler for resources/list requests", async () => {
570+
const requestParams = {};
571+
const resources = [{ uri: "test://resource", name: "Test" }];
572+
const receivedRequests: unknown[] = [];
573+
574+
bridge.onlistresources = async (params) => {
575+
receivedRequests.push(params);
576+
return { resources };
577+
};
578+
579+
await bridge.connect(bridgeTransport);
580+
await app.connect(appTransport);
581+
582+
// App sends resources/list request via the protocol's request method
583+
const result = await app.request(
584+
{ method: "resources/list", params: requestParams },
585+
ListResourcesResultSchema,
586+
);
587+
588+
expect(receivedRequests).toHaveLength(1);
589+
expect(receivedRequests[0]).toMatchObject(requestParams);
590+
expect(result.resources).toEqual(resources);
591+
});
592+
593+
it("onreadresource setter registers handler for resources/read requests", async () => {
594+
const requestParams = { uri: "test://resource" };
595+
const contents = [{ uri: "test://resource", text: "content" }];
596+
const receivedRequests: unknown[] = [];
597+
598+
bridge.onreadresource = async (params) => {
599+
receivedRequests.push(params);
600+
return { contents: [{ uri: params.uri, text: "content" }] };
601+
};
602+
603+
await bridge.connect(bridgeTransport);
604+
await app.connect(appTransport);
605+
606+
const result = await app.request(
607+
{ method: "resources/read", params: requestParams },
608+
ReadResourceResultSchema,
609+
);
610+
611+
expect(receivedRequests).toHaveLength(1);
612+
expect(receivedRequests[0]).toMatchObject(requestParams);
613+
expect(result.contents).toEqual(contents);
614+
});
615+
616+
it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => {
617+
const requestParams = {};
618+
const resourceTemplates = [
619+
{ uriTemplate: "test://{id}", name: "Test Template" },
620+
];
621+
const receivedRequests: unknown[] = [];
622+
623+
bridge.onlistresourcetemplates = async (params) => {
624+
receivedRequests.push(params);
625+
return { resourceTemplates };
626+
};
627+
628+
await bridge.connect(bridgeTransport);
629+
await app.connect(appTransport);
630+
631+
const result = await app.request(
632+
{ method: "resources/templates/list", params: requestParams },
633+
ListResourceTemplatesResultSchema,
634+
);
635+
636+
expect(receivedRequests).toHaveLength(1);
637+
expect(receivedRequests[0]).toMatchObject(requestParams);
638+
expect(result.resourceTemplates).toEqual(resourceTemplates);
639+
});
640+
641+
it("onlistprompts setter registers handler for prompts/list requests", async () => {
642+
const requestParams = {};
643+
const prompts = [{ name: "test-prompt" }];
644+
const receivedRequests: unknown[] = [];
645+
646+
bridge.onlistprompts = async (params) => {
647+
receivedRequests.push(params);
648+
return { prompts };
649+
};
650+
651+
await bridge.connect(bridgeTransport);
652+
await app.connect(appTransport);
653+
654+
const result = await app.request(
655+
{ method: "prompts/list", params: requestParams },
656+
ListPromptsResultSchema,
657+
);
658+
659+
expect(receivedRequests).toHaveLength(1);
660+
expect(receivedRequests[0]).toMatchObject(requestParams);
661+
expect(result.prompts).toEqual(prompts);
662+
});
663+
664+
it("sendToolListChanged sends notification to app", async () => {
665+
const receivedNotifications: unknown[] = [];
666+
app.setNotificationHandler(ToolListChangedNotificationSchema, (n) => {
667+
receivedNotifications.push(n.params);
668+
});
669+
670+
await bridge.connect(bridgeTransport);
671+
await app.connect(appTransport);
672+
673+
bridge.sendToolListChanged();
674+
await flush();
675+
676+
expect(receivedNotifications).toHaveLength(1);
677+
});
678+
679+
it("sendResourceListChanged sends notification to app", async () => {
680+
const receivedNotifications: unknown[] = [];
681+
app.setNotificationHandler(ResourceListChangedNotificationSchema, (n) => {
682+
receivedNotifications.push(n.params);
683+
});
684+
685+
await bridge.connect(bridgeTransport);
686+
await app.connect(appTransport);
687+
688+
bridge.sendResourceListChanged();
689+
await flush();
690+
691+
expect(receivedNotifications).toHaveLength(1);
692+
});
693+
694+
it("sendPromptListChanged sends notification to app", async () => {
695+
const receivedNotifications: unknown[] = [];
696+
app.setNotificationHandler(PromptListChangedNotificationSchema, (n) => {
697+
receivedNotifications.push(n.params);
698+
});
699+
700+
await bridge.connect(bridgeTransport);
701+
await app.connect(appTransport);
702+
703+
bridge.sendPromptListChanged();
704+
await flush();
705+
706+
expect(receivedNotifications).toHaveLength(1);
707+
});
708+
});
511709
});

0 commit comments

Comments
 (0)