diff --git a/AGENTS.md b/AGENTS.md index 084fae9..fd8fe8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,13 @@ ## Error handling cues -- Meta-tools return JSON with `success` and `message` fields. Read `message` to adapt decisions (e.g., policy denial, already active, limits exceeded). +Meta-tool return formats: +- `enable_toolset` and `disable_toolset` return `{ success: boolean, message: string }` +- `list_tools` returns `{ tools: string[], toolsetToTools: Record }` +- `list_toolsets` returns `{ toolsets: Array<{ key, active, definition, tools }> }` +- `describe_toolset` returns `{ key, active, definition, tools }` or `{ error: string }` if unknown + +For `enable_toolset`/`disable_toolset`, read `message` to adapt decisions (e.g., policy denial, already active, limits exceeded). ## HTTP endpoints diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aec6c8a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Toolception is a dynamic MCP (Model Context Protocol) server toolkit for runtime toolset management. It allows grouping tools into toolsets and exposing only what's needed, when it's needed—reducing prompt/tool surface area for LLMs. + +## Common Commands + +```bash +# Build the library +npm run build + +# Run in watch mode during development +npm run dev + +# Type check without emitting +npm run typecheck + +# Run all tests +npm run test + +# Run tests once (no watch) +npm run test:run + +# Run tests with coverage +npm run test:coverage + +# Run a single test file +npx vitest run tests/toolRegistry.test.ts + +# Run smoke tests (start server in one terminal, client in another) +npm run dev:server-demo +npm run dev:client-demo +``` + +## Architecture + +### Core Components + +**ServerOrchestrator** (`src/core/ServerOrchestrator.ts`) +- Entry point that wires together all components +- Resolves startup mode (DYNAMIC vs STATIC) from configuration +- Creates ModuleResolver, DynamicToolManager, and ToolRegistry +- Registers meta-tools based on mode + +**DynamicToolManager** (`src/core/DynamicToolManager.ts`) +- Manages toolset lifecycle (enable/disable) +- Enforces exposure policies (allowlist, denylist, maxActiveToolsets) +- Registers tools with the MCP server +- Tracks active toolsets and sends change notifications + +**ToolRegistry** (`src/core/ToolRegistry.ts`) +- Central registry preventing tool name collisions +- Handles namespacing (e.g., `toolset.toolname`) +- Maps toolsets to their registered tools + +**ModuleResolver** (`src/mode/ModuleResolver.ts`) +- Resolves tools from toolset definitions +- Loads module-produced tools via moduleLoaders +- Validates toolset names against catalog + +### Server Creation APIs + +Two main factory functions in `src/server/`: +- `createMcpServer` - Standard server with DYNAMIC or STATIC modes +- `createPermissionBasedMcpServer` - Per-client toolset access control + +### HTTP Transport + +**FastifyTransport** (`src/http/FastifyTransport.ts`) +- Fastify-based HTTP transport for MCP protocol +- Handles SSE streams, JSON-RPC requests +- Per-client server instances in DYNAMIC mode + +**PermissionAwareFastifyTransport** (`src/http/PermissionAwareFastifyTransport.ts`) +- Extends FastifyTransport with permission checking +- Supports header-based or config-based permissions + +### Key Types (`src/types/index.ts`) + +- `McpToolDefinition` - Tool with name, description, inputSchema, handler, optional annotations +- `ToolSetDefinition` - Groups tools with name, description, optional modules +- `ToolSetCatalog` - Record of toolset key to definition +- `ExposurePolicy` - Controls maxActiveToolsets, allowlist, denylist, namespacing +- `PermissionConfig` - Header or config-based permission source + +### Meta-tools (DYNAMIC mode) + +Registered in `src/meta/registerMetaTools.ts`: +- `enable_toolset` / `disable_toolset` - Activate/deactivate toolsets +- `list_toolsets` / `describe_toolset` - Discovery +- `list_tools` - List currently registered tools + +## Testing Patterns + +Tests use Vitest with in-memory mocks. Key patterns: +- Fake MCP server in `tests/helpers/fakes.ts` +- Unit tests alongside integration tests in `tests/` +- Smoke E2E tests in `tests/smoke-e2e/` for manual server/client testing + +## Build System + +- Vite for bundling (`vite.config.ts`) +- vite-plugin-dts for TypeScript declarations +- ESM-only output (`"type": "module"`) +- Node.js >= 25.3.0 required diff --git a/package-lock.json b/package-lock.json index 8ddc901..1936a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolception", - "version": "0.5.3", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolception", - "version": "0.5.3", + "version": "0.5.5", "license": "Apache-2.0", "dependencies": { "@fastify/cors": "^10.0.1", diff --git a/tests/e2e/dynamicMode.e2e.test.ts b/tests/e2e/dynamicMode.e2e.test.ts new file mode 100644 index 0000000..a7dace6 --- /dev/null +++ b/tests/e2e/dynamicMode.e2e.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createMcpServer } from "../../src/index.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"; +import { + getAvailablePort, + testCatalog, + extractToolNames, + extractTextContent, + parseToolResponse, +} from "./helpers.js"; + +describe("DYNAMIC mode E2E", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createMcpServer({ + createServer: () => + new McpServer({ + name: "test-dynamic", + version: "1.0.0", + }, + { capabilities: { tools: { listChanged: true } }}, + ), + catalog: testCatalog, + startup: { mode: "DYNAMIC" }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("starts with only meta-tools available", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-1" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Should have meta-tools + expect(toolNames).toContain("enable_toolset"); + expect(toolNames).toContain("disable_toolset"); + expect(toolNames).toContain("list_toolsets"); + expect(toolNames).toContain("describe_toolset"); + expect(toolNames).toContain("list_tools"); + + // Should NOT have user tools yet + expect(toolNames).not.toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + + it("enables toolsets and makes tools available", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-2" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // Enable core toolset + const enableResult = await client.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + + const parsed = parseToolResponse<{ success: boolean; message: string }>( + enableResult + ); + expect(parsed.success).toBe(true); + expect(parsed.message).toContain("enabled successfully"); + + // Now core.ping should be available + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + expect(toolNames).toContain("core.ping"); + + await client.close(); + }); + + it("calls enabled tools successfully", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-3" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // Enable and call + await client.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + + const pingResult = await client.callTool({ + name: "core.ping", + arguments: {}, + } as any); + + expect(extractTextContent(pingResult)).toBe("pong"); + + await client.close(); + }); + + it("list_toolsets shows available and active status", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-4" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // List before enabling + const beforeResult = await client.callTool({ + name: "list_toolsets", + arguments: {}, + } as any); + + const before = parseToolResponse<{ toolsets: any[] }>(beforeResult); + expect(before.toolsets).toHaveLength(2); + + const coreBefore = before.toolsets.find((ts) => ts.key === "core"); + expect(coreBefore.active).toBe(false); + + // Enable core + await client.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + + // List after enabling + const afterResult = await client.callTool({ + name: "list_toolsets", + arguments: {}, + } as any); + + const after = parseToolResponse<{ toolsets: any[] }>(afterResult); + const coreAfter = after.toolsets.find((ts) => ts.key === "core"); + expect(coreAfter.active).toBe(true); + expect(coreAfter.tools).toContain("core.ping"); + + await client.close(); + }); + + it("disables toolsets", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-5" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // Enable then disable + await client.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + + const disableResult = await client.callTool({ + name: "disable_toolset", + arguments: { name: "core" }, + } as any); + + const parsed = parseToolResponse<{ success: boolean; message: string }>( + disableResult + ); + expect(parsed.success).toBe(true); + expect(parsed.message).toContain("disabled"); + + // Verify in list_toolsets + const listResult = await client.callTool({ + name: "list_toolsets", + arguments: {}, + } as any); + + const list = parseToolResponse<{ toolsets: any[] }>(listResult); + const core = list.toolsets.find((ts) => ts.key === "core"); + expect(core.active).toBe(false); + + await client.close(); + }); + + it("handles enable_toolset errors gracefully", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-6" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // Try to enable non-existent toolset + const result = await client.callTool({ + name: "enable_toolset", + arguments: { name: "nonexistent" }, + } as any); + + const parsed = parseToolResponse<{ success: boolean; message: string }>( + result + ); + expect(parsed.success).toBe(false); + expect(parsed.message).toBeDefined(); + + await client.close(); + }); + + it("describe_toolset returns toolset details", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "test-client-7" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const result = await client.callTool({ + name: "describe_toolset", + arguments: { name: "core" }, + } as any); + + const parsed = parseToolResponse<{ + key: string; + active: boolean; + definition: { name: string; description: string }; + tools: string[]; + }>(result); + + expect(parsed.key).toBe("core"); + expect(parsed.definition.name).toBe("Core"); + expect(parsed.definition.description).toBe("Core utilities"); + + await client.close(); + }); + + it("each client has independent toolset state", async () => { + // Client 1 enables core + const transport1 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "independent-client-1" } } } + ); + const client1 = new Client({ name: "test", version: "1.0.0" }); + await client1.connect(transport1); + + await client1.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + + // Client 2 should not have core enabled + const transport2 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "independent-client-2" } } } + ); + const client2 = new Client({ name: "test", version: "1.0.0" }); + await client2.connect(transport2); + + const tools2 = await client2.listTools(); + const toolNames2 = extractToolNames(tools2); + + // Client 2 should NOT have core.ping (each client is independent) + expect(toolNames2).not.toContain("core.ping"); + + await client1.close(); + await client2.close(); + }); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..30d73ec --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,78 @@ +import { createServer } from "net"; +import type { ToolSetCatalog } from "../../src/types/index.js"; + +/** + * Get an available port for the test server. + * Uses a TCP server to find an available port, then closes it. + */ +export function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Could not get port"))); + } + }); + }); +} + +/** + * Common test catalog with core and admin toolsets. + */ +export const testCatalog: ToolSetCatalog = { + core: { + name: "Core", + description: "Core utilities", + tools: [ + { + name: "ping", + description: "Returns pong", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ + content: [{ type: "text", text: "pong" }], + }), + }, + ], + }, + admin: { + name: "Admin", + description: "Admin tools", + tools: [ + { + name: "reset", + description: "Reset system", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ + content: [{ type: "text", text: "reset done" }], + }), + }, + ], + }, +}; + +/** + * Helper to extract tool names from MCP listTools response + */ +export function extractToolNames(listToolsResponse: any): string[] { + return (listToolsResponse?.tools?.map((t: any) => t.name) ?? []) as string[]; +} + +/** + * Helper to extract text content from MCP callTool response + */ +export function extractTextContent(callToolResponse: any): string { + return (callToolResponse?.content?.[0]?.text ?? "") as string; +} + +/** + * Helper to parse JSON from MCP tool response + */ +export function parseToolResponse(callToolResponse: any): T { + const text = extractTextContent(callToolResponse); + return JSON.parse(text) as T; +} diff --git a/tests/e2e/permissionBased.e2e.test.ts b/tests/e2e/permissionBased.e2e.test.ts new file mode 100644 index 0000000..54ef37e --- /dev/null +++ b/tests/e2e/permissionBased.e2e.test.ts @@ -0,0 +1,472 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createPermissionBasedMcpServer } from "../../src/index.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"; +import { + getAvailablePort, + testCatalog, + extractToolNames, + extractTextContent, +} from "./helpers.js"; + +describe("Permission-based E2E", () => { + describe("header-based permissions", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createPermissionBasedMcpServer({ + createServer: () => + new McpServer({ name: "test-header", version: "1.0.0" }), + catalog: testCatalog, + permissions: { source: "headers" }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("grants toolsets based on mcp-toolset-permissions header", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "mcp-client-id": "header-client-1", + "mcp-toolset-permissions": "core,admin", + }, + }, + } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).toContain("admin.reset"); + + await client.close(); + }); + + it("grants only requested toolsets", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "mcp-client-id": "header-client-2", + "mcp-toolset-permissions": "core", + }, + }, + } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + + it("calls tools from permitted toolsets", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "mcp-client-id": "header-client-3", + "mcp-toolset-permissions": "core", + }, + }, + } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const result = await client.callTool({ + name: "core.ping", + arguments: {}, + } as any); + + expect(extractTextContent(result)).toBe("pong"); + + await client.close(); + }); + + it("handles whitespace in permission header", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "mcp-client-id": "header-client-4", + "mcp-toolset-permissions": " core , admin ", + }, + }, + } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).toContain("admin.reset"); + + await client.close(); + }); + + it("does not expose meta-tools for changing permissions", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "mcp-client-id": "header-client-5", + "mcp-toolset-permissions": "core", + }, + }, + } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Should NOT have enable/disable meta-tools + expect(toolNames).not.toContain("enable_toolset"); + expect(toolNames).not.toContain("disable_toolset"); + + await client.close(); + }); + }); + + describe("config-based with staticMap", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createPermissionBasedMcpServer({ + createServer: () => + new McpServer({ name: "test-static-map", version: "1.0.0" }), + catalog: testCatalog, + permissions: { + source: "config", + staticMap: { + "admin-client": ["core", "admin"], + "user-client": ["core"], + }, + defaultPermissions: [], + }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("grants full permissions to admin client", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "admin-client" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).toContain("admin.reset"); + + await client.close(); + }); + + it("grants limited permissions to user client", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "user-client" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + + it("grants default (empty) permissions to unknown client", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "unknown-client" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + // When client has no permissions, they may have no tools at all + // which can cause listTools to fail with "Method not found" + try { + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + // If we get here, verify no user tools + expect(toolNames).not.toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + } catch (error: any) { + // "Method not found" is expected when client has zero tools + expect(error.message).toContain("Method not found"); + } + + await client.close(); + }); + + it("calls tools successfully for authorized clients", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "admin-client" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const pingResult = await client.callTool({ + name: "core.ping", + arguments: {}, + } as any); + expect(extractTextContent(pingResult)).toBe("pong"); + + const resetResult = await client.callTool({ + name: "admin.reset", + arguments: {}, + } as any); + expect(extractTextContent(resetResult)).toBe("reset done"); + + await client.close(); + }); + }); + + describe("config-based with resolver", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createPermissionBasedMcpServer({ + createServer: () => + new McpServer({ name: "test-resolver", version: "1.0.0" }), + catalog: testCatalog, + permissions: { + source: "config", + resolver: (clientId: string) => { + if (clientId.startsWith("admin-")) return ["core", "admin"]; + if (clientId.startsWith("user-")) return ["core"]; + return []; + }, + defaultPermissions: [], + }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("grants permissions based on admin- prefix", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "admin-123" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).toContain("admin.reset"); + + await client.close(); + }); + + it("grants permissions based on user- prefix", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "user-456" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + expect(toolNames).toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + + it("grants empty permissions for unknown prefix", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "unknown-789" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + // When client has no permissions, they may have no tools at all + // which can cause listTools to fail with "Method not found" + try { + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + expect(toolNames).not.toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + } catch (error: any) { + // "Method not found" is expected when client has zero tools + expect(error.message).toContain("Method not found"); + } + + await client.close(); + }); + + it("each client ID resolves independently", async () => { + // Admin client + const adminTransport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "admin-abc" } } } + ); + const adminClient = new Client({ name: "test", version: "1.0.0" }); + await adminClient.connect(adminTransport); + + // User client + const userTransport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "user-xyz" } } } + ); + const userClient = new Client({ name: "test", version: "1.0.0" }); + await userClient.connect(userTransport); + + const adminTools = await adminClient.listTools(); + const userTools = await userClient.listTools(); + + const adminNames = extractToolNames(adminTools); + const userNames = extractToolNames(userTools); + + // Admin has admin.reset, user does not + expect(adminNames).toContain("admin.reset"); + expect(userNames).not.toContain("admin.reset"); + + // Both have core.ping + expect(adminNames).toContain("core.ping"); + expect(userNames).toContain("core.ping"); + + await adminClient.close(); + await userClient.close(); + }); + }); + + describe("config-based with both staticMap and resolver", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createPermissionBasedMcpServer({ + createServer: () => + new McpServer({ name: "test-combined", version: "1.0.0" }), + catalog: testCatalog, + permissions: { + source: "config", + // Resolver takes precedence + resolver: (clientId: string) => { + if (clientId === "resolver-override") return ["admin"]; + // Return undefined/null to fall through to staticMap + return undefined as any; + }, + staticMap: { + "static-client": ["core"], + }, + defaultPermissions: ["core"], + }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("resolver takes precedence over staticMap", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "resolver-override" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Resolver returned only admin + expect(toolNames).toContain("admin.reset"); + expect(toolNames).not.toContain("core.ping"); + + await client.close(); + }); + + it("falls back to staticMap when resolver returns undefined", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "static-client" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Static map has core for this client + expect(toolNames).toContain("core.ping"); + + await client.close(); + }); + + it("falls back to defaultPermissions for unknown clients", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "completely-unknown" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Default permissions include core + expect(toolNames).toContain("core.ping"); + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + }); +}); diff --git a/tests/e2e/staticMode.e2e.test.ts b/tests/e2e/staticMode.e2e.test.ts new file mode 100644 index 0000000..fd059d2 --- /dev/null +++ b/tests/e2e/staticMode.e2e.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createMcpServer } from "../../src/index.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"; +import { + getAvailablePort, + testCatalog, + extractToolNames, + extractTextContent, +} from "./helpers.js"; + +describe("STATIC mode E2E", () => { + describe("with specific toolsets", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createMcpServer({ + createServer: () => + new McpServer({ + name: "test-static", + version: "1.0.0", + }), + catalog: testCatalog, + startup: { mode: "STATIC", toolsets: ["core"] }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("has preloaded tools immediately available", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "static-client-1" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Core tools should be available immediately + expect(toolNames).toContain("core.ping"); + + // Admin was not preloaded + expect(toolNames).not.toContain("admin.reset"); + + await client.close(); + }); + + it("does not have dynamic meta-tools in STATIC mode", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "static-client-2" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Should have preloaded tools + expect(toolNames).toContain("core.ping"); + + // Should NOT have dynamic meta-tools (registerMetaTools defaults to false in STATIC mode) + expect(toolNames).not.toContain("enable_toolset"); + expect(toolNames).not.toContain("disable_toolset"); + expect(toolNames).not.toContain("list_toolsets"); + expect(toolNames).not.toContain("describe_toolset"); + + await client.close(); + }); + + it("calls preloaded tools successfully", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "static-client-3" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const result = await client.callTool({ + name: "core.ping", + arguments: {}, + } as any); + + expect(extractTextContent(result)).toBe("pong"); + + await client.close(); + }); + + it( + "multiple clients share the same toolset state", + async () => { + // Test that two separate clients see the same tools + const transport1 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "shared-client-1" } } } + ); + const client1 = new Client({ name: "test", version: "1.0.0" }); + await client1.connect(transport1); + + const tools1 = await client1.listTools(); + const names1 = extractToolNames(tools1); + expect(names1).toContain("core.ping"); + await client1.close(); + + // Second client should see the same tools + const transport2 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "shared-client-2" } } } + ); + const client2 = new Client({ name: "test", version: "1.0.0" }); + await client2.connect(transport2); + + const tools2 = await client2.listTools(); + const names2 = extractToolNames(tools2); + expect(names2).toContain("core.ping"); + await client2.close(); + }, + 10000 + ); + }); + + describe("with ALL toolsets", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createMcpServer({ + createServer: () => + new McpServer({ + name: "test-static-all", + version: "1.0.0", + }), + catalog: testCatalog, + startup: { mode: "STATIC", toolsets: "ALL" }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("has all catalog toolsets preloaded", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "all-client-1" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = extractToolNames(tools); + + // Both core and admin should be preloaded + expect(toolNames).toContain("core.ping"); + expect(toolNames).toContain("admin.reset"); + + await client.close(); + }); + + it("calls all preloaded tools successfully", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "all-client-2" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + const pingResult = await client.callTool({ + name: "core.ping", + arguments: {}, + } as any); + expect(extractTextContent(pingResult)).toBe("pong"); + + const resetResult = await client.callTool({ + name: "admin.reset", + arguments: {}, + } as any); + expect(extractTextContent(resetResult)).toBe("reset done"); + + await client.close(); + }); + }); +}); diff --git a/tests/metaTools.test.ts b/tests/metaTools.test.ts new file mode 100644 index 0000000..b8bc2cc --- /dev/null +++ b/tests/metaTools.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect } from "vitest"; +import { registerMetaTools } from "../src/meta/registerMetaTools.js"; +import { DynamicToolManager } from "../src/core/DynamicToolManager.js"; +import { ModuleResolver } from "../src/mode/ModuleResolver.js"; +import { ToolRegistry } from "../src/core/ToolRegistry.js"; +import { createFakeMcpServer, type RegisteredTool } from "./helpers/fakes.js"; + +describe("Meta-tools return formats", () => { + const catalog = { + core: { + name: "Core", + description: "Core utilities", + tools: [ + { + name: "ping", + description: "Returns pong", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: [{ type: "text", text: "pong" }] }), + }, + ], + }, + ext: { + name: "Extensions", + description: "Extra tools", + modules: ["ext"], + decisionCriteria: "Use when you need extended functionality", + }, + } as any; + + const moduleLoaders = { + ext: async () => [ + { + name: "echo", + description: "Echoes text", + inputSchema: { type: "object", properties: { text: { type: "string" } } }, + handler: async ({ text }: { text: string }) => ({ + content: [{ type: "text", text }], + }), + }, + ], + }; + + function createTestSetup() { + const { server, tools } = createFakeMcpServer(); + const resolver = new ModuleResolver({ catalog, moduleLoaders }); + const toolRegistry = new ToolRegistry({ namespaceWithToolset: true }); + const manager = new DynamicToolManager({ + server, + resolver, + toolRegistry, + }); + registerMetaTools(server, manager, { mode: "DYNAMIC" }); + return { server, tools, manager }; + } + + function findTool(tools: RegisteredTool[], name: string): RegisteredTool | undefined { + return tools.find((t) => t.name === name); + } + + async function callTool(tools: RegisteredTool[], name: string, args: any = {}): Promise { + const tool = findTool(tools, name); + if (!tool) throw new Error(`Tool '${name}' not found`); + const result = await tool.handler(args); + const text = result?.content?.[0]?.text; + return text ? JSON.parse(text) : result; + } + + describe("list_tools", () => { + it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "list_tools"); + + expect(result).toHaveProperty("tools"); + expect(result).toHaveProperty("toolsetToTools"); + expect(Array.isArray(result.tools)).toBe(true); + expect(typeof result.toolsetToTools).toBe("object"); + // No toolsets enabled, so no user tools (only meta-tools not tracked) + expect(result.tools).toEqual([]); + expect(result.toolsetToTools).toEqual({}); + }); + + it("returns correct structure after enabling toolsets", async () => { + const { tools, manager } = createTestSetup(); + + await manager.enableToolset("core"); + + const result = await callTool(tools, "list_tools"); + + expect(result.tools).toContain("core.ping"); + expect(result.toolsetToTools).toHaveProperty("core"); + expect(result.toolsetToTools.core).toContain("core.ping"); + }); + }); + + describe("list_toolsets", () => { + it("returns { toolsets: [...] } with all available toolsets", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "list_toolsets"); + + expect(result).toHaveProperty("toolsets"); + expect(Array.isArray(result.toolsets)).toBe(true); + expect(result.toolsets.length).toBe(2); // core and ext + + const coreToolset = result.toolsets.find((ts: any) => ts.key === "core"); + expect(coreToolset).toBeDefined(); + expect(coreToolset.active).toBe(false); + expect(coreToolset.definition).toHaveProperty("name", "Core"); + expect(coreToolset.definition).toHaveProperty("description", "Core utilities"); + + const extToolset = result.toolsets.find((ts: any) => ts.key === "ext"); + expect(extToolset).toBeDefined(); + expect(extToolset.active).toBe(false); + expect(extToolset.definition).toHaveProperty("name", "Extensions"); + expect(extToolset.definition.modules).toContain("ext"); + expect(extToolset.definition.decisionCriteria).toBe("Use when you need extended functionality"); + }); + + it("reflects active status after enabling", async () => { + const { tools, manager } = createTestSetup(); + + await manager.enableToolset("core"); + + const result = await callTool(tools, "list_toolsets"); + const coreToolset = result.toolsets.find((ts: any) => ts.key === "core"); + const extToolset = result.toolsets.find((ts: any) => ts.key === "ext"); + + expect(coreToolset.active).toBe(true); + expect(coreToolset.tools).toContain("core.ping"); + expect(extToolset.active).toBe(false); + }); + }); + + describe("describe_toolset", () => { + it("returns { key, active, definition, tools } for valid toolset", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "describe_toolset", { name: "core" }); + + expect(result).toHaveProperty("key", "core"); + expect(result).toHaveProperty("active", false); + expect(result).toHaveProperty("definition"); + expect(result.definition.name).toBe("Core"); + expect(result.definition.description).toBe("Core utilities"); + expect(result).toHaveProperty("tools"); + expect(Array.isArray(result.tools)).toBe(true); + }); + + it("returns { error: string } for unknown toolset", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "describe_toolset", { name: "nonexistent" }); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Unknown toolset"); + expect(result.error).toContain("nonexistent"); + }); + + it("includes tools after toolset is enabled", async () => { + const { tools, manager } = createTestSetup(); + + await manager.enableToolset("core"); + + const result = await callTool(tools, "describe_toolset", { name: "core" }); + expect(result.active).toBe(true); + expect(result.tools).toContain("core.ping"); + }); + }); + + describe("enable_toolset", () => { + it("returns { success: true, message: string } on success", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "enable_toolset", { name: "core" }); + + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("message"); + expect(typeof result.message).toBe("string"); + expect(result.message).toContain("enabled successfully"); + }); + + it("returns { success: false, message: string } for unknown toolset", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "enable_toolset", { name: "unknown" }); + + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("message"); + expect(typeof result.message).toBe("string"); + }); + + it("returns { success: false, message: string } when already enabled", async () => { + const { tools } = createTestSetup(); + + // Enable first time + await callTool(tools, "enable_toolset", { name: "core" }); + + // Try to enable again + const result = await callTool(tools, "enable_toolset", { name: "core" }); + + expect(result.success).toBe(false); + expect(result.message).toContain("already enabled"); + }); + }); + + describe("disable_toolset", () => { + it("returns { success: true, message: string } on success", async () => { + const { tools } = createTestSetup(); + + // Enable first + await callTool(tools, "enable_toolset", { name: "core" }); + + // Then disable + const result = await callTool(tools, "disable_toolset", { name: "core" }); + + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("message"); + expect(typeof result.message).toBe("string"); + expect(result.message).toContain("disabled successfully"); + }); + + it("returns { success: false, message: string } when not active", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "disable_toolset", { name: "core" }); + + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("message"); + expect(result.message).toContain("not currently active"); + }); + + it("returns { success: false, message: string } for unknown toolset", async () => { + const { tools } = createTestSetup(); + const result = await callTool(tools, "disable_toolset", { name: "unknown" }); + + expect(result.success).toBe(false); + expect(typeof result.message).toBe("string"); + }); + }); + + describe("STATIC mode meta-tools", () => { + it("only registers list_tools in STATIC mode", () => { + const { server, tools } = createFakeMcpServer(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + registerMetaTools(server, manager, { mode: "STATIC" }); + + const toolNames = tools.map((t) => t.name); + expect(toolNames).toContain("list_tools"); + expect(toolNames).not.toContain("enable_toolset"); + expect(toolNames).not.toContain("disable_toolset"); + expect(toolNames).not.toContain("list_toolsets"); + expect(toolNames).not.toContain("describe_toolset"); + }); + }); +}); diff --git a/tests/moduleResolver.test.ts b/tests/moduleResolver.test.ts index 3c50199..8f8affe 100644 --- a/tests/moduleResolver.test.ts +++ b/tests/moduleResolver.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { ModuleResolver } from "../src/mode/ModuleResolver.js"; describe("ModuleResolver", () => { @@ -42,4 +42,120 @@ describe("ModuleResolver", () => { const tools = await r.resolveToolsForToolsets(["core", "ext"], undefined); expect(tools.map((t) => t.name).sort()).toEqual(["echo", "ping"]); }); + + describe("module loader error handling", () => { + it("logs warning and continues when module loader throws", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const r = new ModuleResolver({ + catalog: { + core: { + name: "Core", + description: "", + tools: [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ], + }, + broken: { name: "Broken", description: "", modules: ["broken"] }, + } as any, + moduleLoaders: { + broken: async () => { + throw new Error("Module loader exploded"); + }, + }, + }); + + // Should not throw, just log warning + const tools = await r.resolveToolsForToolsets(["core", "broken"], undefined); + + // Direct tools from core should still be resolved + expect(tools.map((t) => t.name)).toContain("ping"); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it("handles module loader returning empty array", async () => { + const r = new ModuleResolver({ + catalog: { + empty: { name: "Empty", description: "", modules: ["empty"] }, + } as any, + moduleLoaders: { + empty: async () => [], + }, + }); + + const tools = await r.resolveToolsForToolsets(["empty"], undefined); + expect(tools).toEqual([]); + }); + + it("silently skips when module loader is not found", async () => { + const r = new ModuleResolver({ + catalog: { + orphan: { name: "Orphan", description: "", modules: ["nonexistent"] }, + } as any, + moduleLoaders: {}, // No loader for "nonexistent" + }); + + // Should not throw, just return empty array (silently skips) + const tools = await r.resolveToolsForToolsets(["orphan"], undefined); + expect(tools).toEqual([]); + }); + + it("passes context to module loaders", async () => { + const contextReceived: unknown[] = []; + + const r = new ModuleResolver({ + catalog: { + contextual: { name: "Contextual", description: "", modules: ["ctx"] }, + } as any, + moduleLoaders: { + ctx: async (context) => { + contextReceived.push(context); + return [ + { + name: "contextual_tool", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]; + }, + }, + }); + + const testContext = { userId: "test-123", config: { debug: true } }; + const tools = await r.resolveToolsForToolsets(["contextual"], testContext); + + expect(tools.map((t) => t.name)).toContain("contextual_tool"); + expect(contextReceived).toHaveLength(1); + expect(contextReceived[0]).toEqual(testContext); + }); + + it("handles synchronous module loaders", async () => { + const r = new ModuleResolver({ + catalog: { + sync: { name: "Sync", description: "", modules: ["sync"] }, + } as any, + moduleLoaders: { + sync: () => [ + { + name: "sync_tool", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ], + }, + }); + + const tools = await r.resolveToolsForToolsets(["sync"], undefined); + expect(tools.map((t) => t.name)).toContain("sync_tool"); + }); + }); }); diff --git a/tests/toolRegistry.test.ts b/tests/toolRegistry.test.ts index 5b57857..e144037 100644 --- a/tests/toolRegistry.test.ts +++ b/tests/toolRegistry.test.ts @@ -29,4 +29,193 @@ describe("ToolRegistry", () => { reg.add("tool"); expect(() => reg.add("tool")).toThrow(/collision/i); }); + + describe("namespacing edge cases", () => { + it("does not namespace when namespaceWithToolset is false", () => { + const reg = new ToolRegistry({ namespaceWithToolset: false }); + const mapped = reg.mapAndValidate("core", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped[0].name).toBe("ping"); // Not prefixed + }); + + it("handles tool names that already have the correct prefix", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + const mapped = reg.mapAndValidate("core", [ + { + name: "core.ping", // Already has prefix + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + // Should not double-prefix + expect(mapped[0].name).toBe("core.ping"); + }); + + it("handles tool names with dots when namespacing is enabled", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + const mapped = reg.mapAndValidate("my-toolset", [ + { + name: "api.v2.call", // Tool name has internal dots + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped[0].name).toBe("my-toolset.api.v2.call"); + }); + + it("handles tool names with special characters", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + + // Underscores + const mapped1 = reg.mapAndValidate("toolset", [ + { + name: "get_user_data", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped1[0].name).toBe("toolset.get_user_data"); + + // Hyphens + const mapped2 = reg.mapAndValidate("toolset", [ + { + name: "get-user-data", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped2[0].name).toBe("toolset.get-user-data"); + }); + + it("handles long tool names", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + const longName = "a".repeat(200); + const mapped = reg.mapAndValidate("toolset", [ + { + name: longName, + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped[0].name).toBe(`toolset.${longName}`); + }); + + it("handles empty toolset key", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + const mapped = reg.mapAndValidate("", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + // Empty prefix should still work (results in ".ping") + expect(mapped[0].name).toBe(".ping"); + }); + }); + + describe("collision detection", () => { + it("detects collision in mapAndValidate with namespacing", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + + // First registration + const mapped1 = reg.mapAndValidate("core", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + reg.addForToolset("core", mapped1[0].name); + + // Second registration with same name should throw + expect(() => + reg.mapAndValidate("core", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]) + ).toThrow(/collision/i); + }); + + it("detects collision across toolsets when namespacing is disabled", () => { + const reg = new ToolRegistry({ namespaceWithToolset: false }); + + const mapped1 = reg.mapAndValidate("toolset1", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + reg.addForToolset("toolset1", mapped1[0].name); + + // Same tool name from different toolset should collide + expect(() => + reg.mapAndValidate("toolset2", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]) + ).toThrow(/collision/i); + }); + + it("allows same tool name from different toolsets when namespacing is enabled", () => { + const reg = new ToolRegistry({ namespaceWithToolset: true }); + + const mapped1 = reg.mapAndValidate("toolset1", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + reg.addForToolset("toolset1", mapped1[0].name); + + // Same tool name from different toolset should NOT collide (different prefix) + const mapped2 = reg.mapAndValidate("toolset2", [ + { + name: "ping", + description: "", + inputSchema: {}, + handler: async () => ({}), + }, + ]); + expect(mapped2[0].name).toBe("toolset2.ping"); + }); + }); + + describe("has() method", () => { + it("returns true for existing tools", () => { + const reg = new ToolRegistry({ namespaceWithToolset: false }); + reg.add("ping"); + expect(reg.has("ping")).toBe(true); + }); + + it("returns false for non-existing tools", () => { + const reg = new ToolRegistry({ namespaceWithToolset: false }); + expect(reg.has("nonexistent")).toBe(false); + }); + }); });