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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.2.3",
"version": "0.2.4",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
15 changes: 14 additions & 1 deletion src/http/FastifyTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 7 additions & 4 deletions src/server/createMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tests/createMcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
57 changes: 57 additions & 0 deletions tests/fastifyTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>();
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();
});
});