Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/permissions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/permissions/PermissionAwareFastifyTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions tests/e2e/permissionBased.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
});
});
});
Loading