diff --git a/src/permissions/AGENTS.md b/src/permissions/AGENTS.md index 896e459..54c100b 100644 --- a/src/permissions/AGENTS.md +++ b/src/permissions/AGENTS.md @@ -48,6 +48,7 @@ HTTP Request with mcp-client-id header → PermissionResolver.resolve(clientId, headers) → Create STATIC mode server with allowed toolsets only → Return bundle (cached for reuse) + → On initialize: await bundle.orchestrator.isReady() before server.connect() — the SDK refuses capability registration after connect ``` ## Anti-patterns diff --git a/src/permissions/PermissionAwareFastifyTransport.ts b/src/permissions/PermissionAwareFastifyTransport.ts index 5936cc2..7282278 100644 --- a/src/permissions/PermissionAwareFastifyTransport.ts +++ b/src/permissions/PermissionAwareFastifyTransport.ts @@ -346,6 +346,9 @@ export class PermissionAwareFastifyTransport { bundle!.sessions.delete(transport.sessionId); }; try { + // Bundles may preload toolsets asynchronously; the SDK refuses to + // register tool capabilities once connected, so wait for them first. + await bundle.orchestrator.isReady(); await bundle.server.connect(transport); } catch (error) { reply.code(500); diff --git a/tests/e2e/permissionBased.e2e.test.ts b/tests/e2e/permissionBased.e2e.test.ts index 54ef37e..3c3dd99 100644 --- a/tests/e2e/permissionBased.e2e.test.ts +++ b/tests/e2e/permissionBased.e2e.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { createPermissionBasedMcpServer } from "../../src/index.js"; +import { PermissionAwareFastifyTransport } from "../../src/permissions/PermissionAwareFastifyTransport.js"; +import { ServerOrchestrator } from "../../src/core/ServerOrchestrator.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -469,4 +471,73 @@ describe("Permission-based E2E", () => { await client.close(); }); }); + + describe("bundles that preload asynchronously", () => { + const slowCatalog = { + slow: { + name: "Slow", + description: "Loaded asynchronously", + modules: ["slow"], + }, + }; + let transport: PermissionAwareFastifyTransport; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + transport = PermissionAwareFastifyTransport.builder() + .defaultManager( + ServerOrchestrator.builder() + .server(new McpServer({ name: "base", version: "1.0.0" })) + .catalog(testCatalog) + .build() + .getManager() + ) + .createPermissionAwareBundle(async () => { + // STATIC startup loads toolsets in the background; the bundle is + // returned before they finish, so the transport must wait. + const server = new McpServer({ name: "async-bundle", version: "1.0.0" }); + const orchestrator = ServerOrchestrator.builder() + .server(server) + .catalog(slowCatalog) + .moduleLoaders({ + slow: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return testCatalog.core.tools!; + }, + }) + .startup({ mode: "STATIC", toolsets: ["slow"] }) + .registerMetaTools(false) + .build(); + return { + server, + orchestrator, + allowedToolsets: ["slow"], + failedToolsets: [], + }; + }) + .port(port) + .build(); + await transport.start(); + }); + + afterAll(async () => { + await transport.stop(); + }); + + it("lists tools once the bundle finished loading", async () => { + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "async-client" } } } + ) + ); + + const toolNames = extractToolNames(await client.listTools()); + expect(toolNames).toContain("slow.ping"); + + await client.close(); + }); + }); });