diff --git a/package.json b/package.json index 51bc66f..080e838 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.2.3", + "version": "0.2.4", "private": false, "type": "module", "main": "dist/index.js", diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index 03a5163..a466912 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -114,10 +114,12 @@ export class FastifyTransport { let bundle = useCache ? this.clientCache.get(clientId) : null; if (!bundle) { const created = this.createBundle(); + const providedSessions = (created as any).sessions; bundle = { server: created.server, orchestrator: created.orchestrator, - sessions: new Map(), + sessions: + providedSessions instanceof Map ? providedSessions : new Map(), }; if (useCache) this.clientCache.set(clientId, bundle); } @@ -230,6 +232,17 @@ export class FastifyTransport { id: null, }; } + try { + // Best-effort close and evict + if (typeof (transport as any).close === "function") { + try { + await (transport as any).close(); + } catch {} + } + } finally { + if (transport?.sessionId) bundle.sessions.delete(transport.sessionId); + else bundle.sessions.delete(sessionId); + } reply.code(204).send(); return reply; } diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index ab33aeb..8a2e86f 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -71,9 +71,12 @@ export async function createMcpServer(options: CreateMcpServerOptions) { () => { // Create a server + orchestrator bundle // for a new client when needed - const createdServer: McpServer = - mode === "DYNAMIC" ? options.createServer() : baseServer; - const orchestrator = new ServerOrchestrator({ + if (mode === "STATIC") { + // Reuse the base server and orchestrator to avoid duplicate registrations + return { server: baseServer, orchestrator }; + } + const createdServer: McpServer = options.createServer(); + const createdOrchestrator = new ServerOrchestrator({ server: createdServer, catalog: options.catalog, moduleLoaders: options.moduleLoaders, @@ -86,7 +89,7 @@ export async function createMcpServer(options: CreateMcpServerOptions) { ? options.registerMetaTools : mode === "DYNAMIC", }); - return { server: createdServer, orchestrator }; + return { server: createdServer, orchestrator: createdOrchestrator }; }, options.http, options.configSchema diff --git a/tests/createMcpServer.test.ts b/tests/createMcpServer.test.ts index 09d28a1..4a15105 100644 --- a/tests/createMcpServer.test.ts +++ b/tests/createMcpServer.test.ts @@ -124,4 +124,40 @@ describe("createMcpServer", () => { expect(s1.calls.includes("list_tools")).toBe(true); expect(s2.calls.includes("list_tools")).toBe(true); }); + + it("does not re-register tools in STATIC mode across multiple clients", async () => { + const f = makeFakeServerFactory(); + const staticCatalog = { + core: { + name: "Core", + description: "", + tools: [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({ content: [{ type: "text", text: "pong" }] }), + }, + ], + }, + } as any; + + await createMcpServer({ + catalog: staticCatalog, + startup: { mode: "STATIC", toolsets: ["core"] }, + createServer: f.createServer, + }); + + const base = f.created[0]; + // One registration at startup, namespaced by toolset key + expect(base.calls.filter((n) => n === "core.ping").length).toBe(1); + + const bundleFactory = (FastifyTransportMock as any).lastArgs?.[1]; + // Simulate two more clients (bundles) + bundleFactory(); + bundleFactory(); + + // No additional registrations should have occurred on the shared server + expect(base.calls.filter((n) => n === "core.ping").length).toBe(1); + }); }); diff --git a/tests/fastifyTransport.test.ts b/tests/fastifyTransport.test.ts index ed121ed..6cfa669 100644 --- a/tests/fastifyTransport.test.ts +++ b/tests/fastifyTransport.test.ts @@ -41,4 +41,61 @@ describe("FastifyTransport", () => { await transport.stop(); }); + + it("DELETE /mcp evicts session after close", async () => { + // Fake server that supports connect() + const server: any = { + async connect(_t: any) { + // no-op + }, + }; + const resolver = new ModuleResolver({ + catalog: { core: { name: "Core", description: "", tools: [] } } as any, + }); + const manager = new DynamicToolManager({ server, resolver }); + + const app = Fastify({ logger: false }); + // Stub createBundle with a minimal streamable transport-like object + const sessions = new Map(); + const bundle = { server, orchestrator: {} as any, sessions } as any; + + const transport = new FastifyTransport( + manager, + () => bundle, + { port: 0, logger: false, app } + ); + await transport.start(); + + const clientId = "c1"; + // Seed bundle in cache with a non-initialize POST (will 400 but caches bundle) + await app.inject({ + method: "POST", + url: "/mcp", + headers: { "mcp-client-id": clientId }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + // Now create a fake session inside the cached bundle + const createdSessionId = "s-1"; + const storedTransport: any = { + sessionId: createdSessionId, + async handleRequest() {}, + async close() { + this._closed = true; + }, + }; + sessions.set(createdSessionId, storedTransport); + + // Attempt DELETE + const res = await app.inject({ + method: "DELETE", + url: "/mcp", + headers: { "mcp-client-id": clientId, "mcp-session-id": createdSessionId }, + }); + expect(res.statusCode).toBe(204); + expect(storedTransport._closed).toBe(true); + expect(sessions.has(createdSessionId)).toBe(false); + + await transport.stop(); + }); });