From 6fbdcc1950c39335c77d66207ebcc12a68480a3d Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Fri, 28 Nov 2025 15:57:34 +0200 Subject: [PATCH 1/2] fix: npm audit issues and stability bugs --- README.md | 78 ++- package-lock.json | 574 +++--------------- package.json | 2 +- src/core/DynamicToolManager.ts | 218 ++++--- src/core/ServerOrchestrator.ts | 62 +- src/http/FastifyTransport.ts | 40 +- src/meta/registerMetaTools.ts | 79 ++- src/mode/ModeResolver.ts | 24 +- .../PermissionAwareFastifyTransport.ts | 59 +- src/permissions/PermissionResolver.ts | 61 +- .../createPermissionAwareBundle.ts | 48 +- src/server/createMcpServer.ts | 26 +- src/server/createPermissionBasedMcpServer.ts | 59 +- src/session/ClientResourceCache.ts | 82 ++- tests/createPermissionAwareBundle.test.ts | 69 ++- tests/serverOrchestrator.test.ts | 9 +- 16 files changed, 801 insertions(+), 689 deletions(-) diff --git a/README.md b/README.md index 7af022a..38f5c03 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ process.on("SIGTERM", async () => { ## Static startup -Enable some or ALL toolsets at bootstrap. Note: provide a server or factory: +Enable some or ALL toolsets at bootstrap. In STATIC mode, a single server instance is created and reused for all clients: ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -181,26 +181,30 @@ const staticCatalog = { quotes: { name: "Quotes", description: "Market quotes", modules: ["quotes"] }, }; +// Load specific toolsets createMcpServer({ catalog: staticCatalog, startup: { mode: "STATIC", toolsets: ["search", "quotes"] }, http: { port: 3001 }, - server: new McpServer({ - name: "static-1", - version: "0.0.0", - capabilities: { tools: { listChanged: false } }, - }), + createServer: () => + new McpServer({ + name: "static-1", + version: "0.0.0", + capabilities: { tools: { listChanged: false } }, + }), }); +// Load ALL toolsets createMcpServer({ catalog: staticCatalog, startup: { mode: "STATIC", toolsets: "ALL" }, http: { port: 3002 }, - server: new McpServer({ - name: "static-2", - version: "0.0.0", - capabilities: { tools: { listChanged: false } }, - }), + createServer: () => + new McpServer({ + name: "static-2", + version: "0.0.0", + capabilities: { tools: { listChanged: false } }, + }), }); ``` @@ -664,9 +668,11 @@ Startup precedence and validation #### options.registerMetaTools (optional) -`boolean` (default: true in DYNAMIC mode; false in STATIC unless explicitly set) +`boolean` (default: true in DYNAMIC mode; false in STATIC mode) -- Whether to register management tools like `enable_toolset`, `disable_toolset`, `list_tools`. +- Whether to register meta-tools for toolset management. +- In DYNAMIC mode: Registers all meta-tools (`enable_toolset`, `disable_toolset`, `list_toolsets`, `describe_toolset`, `list_tools`). +- In STATIC mode: Only registers `list_tools` (other meta-tools are not applicable since toolsets are fixed at startup). #### options.exposurePolicy (optional) @@ -805,13 +811,17 @@ Same as `createMcpServer` - see [options.moduleLoaders](#optionsmoduleloaders-op `ExposurePolicy` (partial support) -Permission-based servers override certain policy fields: +Permission-based servers only support `namespaceToolsWithSetKey`. Other policy fields are ignored because toolset access is controlled by permissions: + +| Field | Support | +| -------------------------- | ------------------------------------------------- | +| `namespaceToolsWithSetKey` | ✅ Supported (default: true) | +| `allowlist` | ⚠️ Ignored (determined by client permissions) | +| `denylist` | ⚠️ Ignored (use permissions instead) | +| `maxActiveToolsets` | ⚠️ Ignored (determined by permission count) | +| `onLimitExceeded` | ⚠️ Ignored (no toolset limits enforced) | -- `allowlist`: Set automatically based on resolved permissions (cannot be manually configured) -- `maxActiveToolsets`: Set automatically to match permission count -- `namespaceToolsWithSetKey`: Supported (default: true) -- `denylist`: Not supported (use permissions instead) -- `onLimitExceeded`: Not applicable +**Note:** If you provide ignored options, the server will log warnings at startup to alert you. #### options.http (optional) @@ -831,11 +841,21 @@ Same as `createMcpServer` - see [options.context](#optionscontext-optional). ### Meta-tools -Enabled by default when mode is DYNAMIC (or when `registerMetaTools` is true): +Meta-tools are registered based on mode: + +**DYNAMIC mode** (registered by default, or when `registerMetaTools` is true): + +- `enable_toolset` - Enable a toolset by name +- `disable_toolset` - Disable a toolset by name (state only; tools remain registered) +- `list_toolsets` - List available toolsets with active status +- `describe_toolset` - Describe a specific toolset with definition and tools +- `list_tools` - List currently registered tool names + +**STATIC mode** (when `registerMetaTools` is true): + +- `list_tools` - List currently registered tool names -- `enable_toolset`, `disable_toolset`, `list_tools` - Only in DYNAMIC mode: -- `list_toolsets`, `describe_toolset` +Note: In STATIC mode, `enable_toolset`, `disable_toolset`, `list_toolsets`, and `describe_toolset` are not available since toolsets are fixed at startup. ## Permission-based client integration @@ -1344,17 +1364,19 @@ Note on dynamic mode: Both direct and module-produced tools are supported. Modul ## Startup modes -The server operates in one of two primary modes (legacy load-all is not recommended here): +The server operates in one of two primary modes: -1. Dynamic mode (startup.mode = "DYNAMIC") +1. **Dynamic mode** (`startup.mode = "DYNAMIC"`) - - Starts with meta-tools for runtime management: `enable_toolset`, `disable_toolset`, `list_toolsets`, `describe_toolset`, and `list_tools` (always available) + - All meta-tools registered: `enable_toolset`, `disable_toolset`, `list_toolsets`, `describe_toolset`, `list_tools` - Tools are loaded on-demand via meta-tool calls + - Each client gets an isolated server instance - Best for flexible, task-specific workflows where tool needs change -2. Static mode (startup.mode = "STATIC") +2. **Static mode** (`startup.mode = "STATIC"`) - Pre-loads specific toolsets at startup (`toolsets` array or "ALL") - - Meta-tools limited to `list_tools` by default + - Only `list_tools` meta-tool is available (toolsets cannot be changed at runtime) + - A single server instance is reused for all clients - Best for known, consistent tool requirements ## License diff --git a/package-lock.json b/package-lock.json index 6760f00..4e8eae4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolception", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolception", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@fastify/cors": "^10.0.1", @@ -20,7 +20,7 @@ "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.19.0", "typescript": "^5.9.2", - "vite": "^5.3.1", + "vite": "^7.2.4", "vite-plugin-dts": "^4.5.4", "vitest": "^3.2.4" }, @@ -1886,23 +1886,27 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -2098,9 +2102,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2717,9 +2721,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -2808,40 +2812,39 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/import-lazy": { @@ -3659,18 +3662,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/real-require": { @@ -4320,14 +4323,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -4484,21 +4487,24 @@ } }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4507,19 +4513,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -4540,6 +4552,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -4593,436 +4611,6 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", diff --git a/package.json b/package.json index 7b8bbfc..a8f9ef6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.19.0", "typescript": "^5.9.2", - "vite": "^5.3.1", + "vite": "^7.2.4", "vite-plugin-dts": "^4.5.4", "vitest": "^3.2.4" }, diff --git a/src/core/DynamicToolManager.ts b/src/core/DynamicToolManager.ts index f22df2d..69ed6cd 100644 --- a/src/core/DynamicToolManager.ts +++ b/src/core/DynamicToolManager.ts @@ -37,6 +37,21 @@ export class DynamicToolManager { options.toolRegistry ?? new ToolRegistry({ namespaceWithToolset: true }); } + /** + * Sends a tool list change notification if configured. + * Logs warnings on failure instead of throwing. + * @returns Promise that resolves when notification is sent (or skipped) + * @private + */ + private async notifyToolsChanged(): Promise { + if (!this.onToolsListChanged) return; + try { + await this.onToolsListChanged(); + } catch (err) { + console.warn("Failed to send tool list change notification:", err); + } + } + public getAvailableToolsets(): string[] { return this.resolver.getAvailableToolsets(); } @@ -53,8 +68,16 @@ export class DynamicToolManager { return this.activeToolsets.has(name); } + /** + * Enables a single toolset by name. + * Validates the toolset, checks exposure policies, resolves tools, and registers them. + * @param toolsetName - The name of the toolset to enable + * @param skipNotification - If true, skips the tool list change notification (for batch operations) + * @returns Result object with success status and message + */ public async enableToolset( - toolsetName: string + toolsetName: string, + skipNotification = false ): Promise<{ success: boolean; message: string }> { const validation = this.resolver.validateToolsetName(toolsetName); if (!validation.isValid || !validation.sanitized) { @@ -71,62 +94,39 @@ export class DynamicToolManager { }; } + // Check exposure policies BEFORE resolving tools to fail fast + const policyCheck = this.checkExposurePolicy(sanitized); + if (!policyCheck.allowed) { + return { success: false, message: policyCheck.message }; + } + + // Track tools registered for this enable operation to allow rollback + const registeredTools: string[] = []; + try { const resolvedTools = await this.resolver.resolveToolsForToolsets( [sanitized], this.context ); - // Exposure policy checks - if ( - this.exposurePolicy?.allowlist && - !this.exposurePolicy.allowlist.includes(sanitized) - ) { - return { - success: false, - message: `Toolset '${sanitized}' is not allowed by policy.`, - }; - } - if ( - this.exposurePolicy?.denylist && - this.exposurePolicy.denylist.includes(sanitized) - ) { - return { - success: false, - message: `Toolset '${sanitized}' is denied by policy.`, - }; - } - if (this.exposurePolicy?.maxActiveToolsets !== undefined) { - const next = this.activeToolsets.size + 1; - if (next > this.exposurePolicy.maxActiveToolsets) { - this.exposurePolicy.onLimitExceeded?.( - [sanitized], - Array.from(this.activeToolsets) - ); - return { - success: false, - message: `Activation exceeds maxActiveToolsets (${this.exposurePolicy.maxActiveToolsets}).`, - }; - } - } - // Register all resolved tools (direct + module-derived) if (resolvedTools && resolvedTools.length > 0) { const mapped = this.toolRegistry.mapAndValidate( sanitized, resolvedTools ); - this.registerDirectTools(mapped, sanitized); + for (const tool of mapped) { + this.registerSingleTool(tool, sanitized); + registeredTools.push(tool.name); + } } - // Track state (modules no longer tracked) + // Track state only after successful registration this.activeToolsets.add(sanitized); - // Notify list change - try { - await this.onToolsListChanged?.(); - } catch (err) { - console.warn(`Failed to send tool list change notification:`, err); + // Notify list change (unless skipped for batch operations) + if (!skipNotification) { + await this.notifyToolsChanged(); } return { @@ -136,7 +136,15 @@ export class DynamicToolManager { } tools.`, }; } catch (error) { - this.activeToolsets.delete(sanitized); + // Note: We cannot unregister tools from MCP server, but we can track the inconsistency + if (registeredTools.length > 0) { + console.warn( + `Partial failure enabling toolset '${sanitized}'. ` + + `${registeredTools.length} tools were registered but toolset activation failed. ` + + `Tools remain registered due to MCP limitations: ${registeredTools.join(", ")}` + ); + } + // Don't add to activeToolsets since we failed return { success: false, message: `Failed to enable toolset '${sanitized}': ${ @@ -146,6 +154,74 @@ export class DynamicToolManager { } } + /** + * Checks if a toolset is allowed by the exposure policy. + * @param toolsetName - The sanitized toolset name to check + * @returns Object indicating if allowed and reason message if not + * @private + */ + private checkExposurePolicy(toolsetName: string): { + allowed: boolean; + message: string; + } { + if ( + this.exposurePolicy?.allowlist && + !this.exposurePolicy.allowlist.includes(toolsetName) + ) { + return { + allowed: false, + message: `Toolset '${toolsetName}' is not allowed by policy.`, + }; + } + if ( + this.exposurePolicy?.denylist && + this.exposurePolicy.denylist.includes(toolsetName) + ) { + return { + allowed: false, + message: `Toolset '${toolsetName}' is denied by policy.`, + }; + } + if (this.exposurePolicy?.maxActiveToolsets !== undefined) { + const next = this.activeToolsets.size + 1; + if (next > this.exposurePolicy.maxActiveToolsets) { + this.exposurePolicy.onLimitExceeded?.( + [toolsetName], + Array.from(this.activeToolsets) + ); + return { + allowed: false, + message: `Activation exceeds maxActiveToolsets (${this.exposurePolicy.maxActiveToolsets}).`, + }; + } + } + return { allowed: true, message: "" }; + } + + /** + * Registers a single tool with the MCP server. + * @param tool - The tool definition to register + * @param toolsetKey - The toolset key for tracking + * @private + */ + private registerSingleTool(tool: McpToolDefinition, toolsetKey: string): void { + this.server.tool( + tool.name, + tool.description, + tool.inputSchema as Parameters[2], + async (args: Record) => { + return await tool.handler(args); + } + ); + this.toolRegistry.addForToolset(toolsetKey, tool.name); + } + + /** + * Disables a toolset by name. + * Note: Due to MCP limitations, tools remain registered but the toolset is marked inactive. + * @param toolsetName - The name of the toolset to disable + * @returns Result object with success status and message + */ public async disableToolset( toolsetName: string ): Promise<{ success: boolean; message: string }> { @@ -172,11 +248,7 @@ export class DynamicToolManager { // State-only disable; no unregistration support in MCP this.activeToolsets.delete(sanitized); - try { - await this.onToolsListChanged?.(); - } catch (err) { - console.warn(`Failed to send tool list change notification:`, err); - } + await this.notifyToolsChanged(); return { success: true, @@ -196,6 +268,12 @@ export class DynamicToolManager { }; } + /** + * Enables multiple toolsets in a batch operation. + * Sends a single notification after all toolsets are processed. + * @param toolsetNames - Array of toolset names to enable + * @returns Result object with overall success status and individual results + */ public async enableToolsets(toolsetNames: string[]): Promise<{ success: boolean; results: Array<{ @@ -212,9 +290,11 @@ export class DynamicToolManager { message: string; code?: ToolingErrorCode; }> = []; + + // Enable each toolset, skipping individual notifications for (const name of toolsetNames) { try { - const res = await this.enableToolset(name); + const res = await this.enableToolset(name, true); results.push({ name, ...res }); } catch (err) { results.push({ @@ -225,41 +305,27 @@ export class DynamicToolManager { }); } } + const successAll = results.every((r) => r.success); + const anySuccess = results.some((r) => r.success); const message = successAll ? "All toolsets enabled" - : "Some toolsets failed to enable"; - if (results.length > 0) { - try { - await this.onToolsListChanged?.(); - } catch {} - } - return { success: successAll, results, message }; - } + : anySuccess + ? "Some toolsets failed to enable" + : "All toolsets failed to enable"; - private registerDirectTools( - tools: McpToolDefinition[], - toolsetKey?: string - ): void { - for (const tool of tools) { - try { - this.server.tool( - tool.name, - tool.description, - tool.inputSchema as any, - async (args: any) => { - return await tool.handler(args); - } - ); - if (toolsetKey) this.toolRegistry.addForToolset(toolsetKey, tool.name); - else this.toolRegistry.add(tool.name); - } catch (err) { - console.error(`Failed to register direct tool '${tool.name}':`, err); - throw err; - } + // Send a single notification after batch is complete (if any changes occurred) + if (anySuccess) { + await this.notifyToolsChanged(); } + + return { success: successAll, results, message }; } + /** + * Enables all available toolsets in a batch operation. + * @returns Result object with overall success status and individual results + */ public async enableAllToolsets(): Promise<{ success: boolean; results: Array<{ diff --git a/src/core/ServerOrchestrator.ts b/src/core/ServerOrchestrator.ts index 28bc115..174691e 100644 --- a/src/core/ServerOrchestrator.ts +++ b/src/core/ServerOrchestrator.ts @@ -3,13 +3,18 @@ import { ToolsetValidator } from "../mode/ToolsetValidator.js"; import { ModuleResolver } from "../mode/ModuleResolver.js"; import { DynamicToolManager } from "./DynamicToolManager.js"; import { registerMetaTools } from "../meta/registerMetaTools.js"; -import type { ExposurePolicy, Mode, ToolSetCatalog } from "../types/index.js"; +import type { + ExposurePolicy, + Mode, + ModuleLoader, + ToolSetCatalog, +} from "../types/index.js"; import { ToolRegistry } from "./ToolRegistry.js"; export interface ServerOrchestratorOptions { server: McpServer; catalog: ToolSetCatalog; - moduleLoaders?: Record; + moduleLoaders?: Record; exposurePolicy?: ExposurePolicy; context?: unknown; notifyToolsListChanged?: () => Promise | void; @@ -22,6 +27,8 @@ export class ServerOrchestrator { private readonly resolver: ModuleResolver; private readonly manager: DynamicToolManager; private readonly toolsetValidator: ToolsetValidator; + private readonly initPromise: Promise; + private initError: Error | null = null; constructor(options: ServerOrchestratorOptions) { this.toolsetValidator = new ToolsetValidator(); @@ -50,15 +57,56 @@ export class ServerOrchestrator { registerMetaTools(options.server, this.manager, { mode: this.mode }); } - // Startup behavior + // Startup behavior - store promise for async initialization const initial = resolved.toolsets; - if (initial === "ALL") { - void this.manager.enableToolsets(this.resolver.getAvailableToolsets()); - } else if (Array.isArray(initial) && initial.length > 0) { - void this.manager.enableToolsets(initial); + this.initPromise = this.initializeToolsets(initial); + } + + /** + * Initializes toolsets asynchronously during construction. + * Stores any errors for later retrieval via ensureReady(). + * @param initial - The toolsets to initialize or "ALL" + * @returns Promise that resolves when initialization is complete + * @private + */ + private async initializeToolsets( + initial: string[] | "ALL" | undefined + ): Promise { + try { + if (initial === "ALL") { + await this.manager.enableToolsets(this.resolver.getAvailableToolsets()); + } else if (Array.isArray(initial) && initial.length > 0) { + await this.manager.enableToolsets(initial); + } + } catch (error) { + this.initError = + error instanceof Error ? error : new Error(String(error)); + console.error("Failed to initialize toolsets:", this.initError); + } + } + + /** + * Waits for the orchestrator to be fully initialized. + * Call this before using the orchestrator to ensure all toolsets are loaded. + * @throws {Error} If initialization failed + */ + public async ensureReady(): Promise { + await this.initPromise; + if (this.initError) { + throw this.initError; } } + /** + * Checks if the orchestrator has finished initialization. + * Does not throw on error - use ensureReady() for that. + * @returns Promise that resolves to true if ready, false if initialization failed + */ + public async isReady(): Promise { + await this.initPromise; + return this.initError === null; + } + private resolveStartupConfig( startup: { mode?: Exclude; toolsets?: string[] | "ALL" }, catalog: ToolSetCatalog diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index a466912..8706019 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -44,7 +44,12 @@ export class FastifyTransport { server: McpServer; orchestrator: ServerOrchestrator; sessions: Map; - }>(); + }>({ + onEvict: (_key, bundle) => { + // Clean up all sessions when a client bundle is evicted + this.cleanupBundle(bundle); + }, + }); constructor( defaultManager: DynamicToolManager, @@ -255,11 +260,44 @@ export class FastifyTransport { this.app = app; } + /** + * Stops the Fastify server and cleans up all resources. + * Closes all client sessions and clears the cache. + */ public async stop(): Promise { if (!this.app) return; + + // Stop the cache pruning interval and clear all entries (triggers cleanup) + this.clientCache.stop(true); + if (!this.options.app) { await this.app.close(); } this.app = null; } + + /** + * Cleans up resources associated with a client bundle. + * Closes all sessions within the bundle. + * @param bundle - The client bundle to clean up + * @private + */ + private cleanupBundle(bundle: { + server: McpServer; + orchestrator: ServerOrchestrator; + sessions: Map; + }): void { + for (const [sessionId, transport] of bundle.sessions.entries()) { + try { + if (typeof (transport as any).close === "function") { + (transport as any).close().catch((err: unknown) => { + console.warn(`Error closing session ${sessionId}:`, err); + }); + } + } catch (err) { + console.warn(`Error closing session ${sessionId}:`, err); + } + } + bundle.sessions.clear(); + } } diff --git a/src/meta/registerMetaTools.ts b/src/meta/registerMetaTools.ts index f92e5ca..5a1ace7 100644 --- a/src/meta/registerMetaTools.ts +++ b/src/meta/registerMetaTools.ts @@ -3,40 +3,53 @@ import type { Mode } from "../types/index.js"; import { z } from "zod"; import { DynamicToolManager } from "../core/DynamicToolManager.js"; +/** + * Registers meta-tools on the MCP server for toolset management. + * + * In DYNAMIC mode, all meta-tools are registered: + * - enable_toolset, disable_toolset: For runtime toolset management + * - list_toolsets, describe_toolset: For toolset discovery + * - list_tools: For listing registered tools + * + * In STATIC mode, only list_tools is registered since toolsets are fixed at startup. + * + * @param server - The MCP server to register tools on + * @param manager - The DynamicToolManager instance + * @param options - Configuration options including the mode + */ export function registerMetaTools( server: McpServer, manager: DynamicToolManager, options?: { mode?: Exclude } ): void { const mode = options?.mode ?? "DYNAMIC"; - // list_tools is always available - server.tool( - "enable_toolset", - "Enable a toolset by name", - { name: z.string().describe("Toolset name") }, - async (args: any) => { - const { name } = args as { name: string }; - const result = await manager.enableToolset(name); - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; - } - ); - - server.tool( - "disable_toolset", - "Disable a toolset by name (state only)", - { name: z.string().describe("Toolset name") }, - async (args: any) => { - const { name } = args as { name: string }; - const result = await manager.disableToolset(name); - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; - } - ); + // Dynamic-mode only tools: enable/disable toolsets at runtime if (mode === "DYNAMIC") { + server.tool( + "enable_toolset", + "Enable a toolset by name", + { name: z.string().describe("Toolset name") }, + async (args: { name: string }) => { + const result = await manager.enableToolset(args.name); + return { + content: [{ type: "text", text: JSON.stringify(result) }], + }; + } + ); + + server.tool( + "disable_toolset", + "Disable a toolset by name (state only)", + { name: z.string().describe("Toolset name") }, + async (args: { name: string }) => { + const result = await manager.disableToolset(args.name); + return { + content: [{ type: "text", text: JSON.stringify(result) }], + }; + } + ); + server.tool( "list_toolsets", "List available toolsets with active status and definitions", @@ -72,30 +85,29 @@ export function registerMetaTools( "describe_toolset", "Describe a toolset with definition, active status and tools", { name: z.string().describe("Toolset name") }, - async (args: any) => { - const { name } = args as { name: string }; - const def = manager.getToolsetDefinition(name); + async (args: { name: string }) => { + const def = manager.getToolsetDefinition(args.name); const byToolset = manager.getStatus().toolsetToTools; if (!def) { return { content: [ { type: "text", - text: JSON.stringify({ error: `Unknown toolset '${name}'` }), + text: JSON.stringify({ error: `Unknown toolset '${args.name}'` }), }, ], }; } const payload = { - key: name, - active: manager.isActive(name), + key: args.name, + active: manager.isActive(args.name), definition: { name: def.name, description: def.description, modules: def.modules ?? [], decisionCriteria: def.decisionCriteria ?? undefined, }, - tools: byToolset[name] ?? [], + tools: byToolset[args.name] ?? [], }; return { content: [{ type: "text", text: JSON.stringify(payload) }], @@ -104,6 +116,7 @@ export function registerMetaTools( ); } + // list_tools is available in both modes server.tool( "list_tools", "List currently registered tool names (best effort)", diff --git a/src/mode/ModeResolver.ts b/src/mode/ModeResolver.ts index 356d791..e9ca320 100644 --- a/src/mode/ModeResolver.ts +++ b/src/mode/ModeResolver.ts @@ -116,18 +116,30 @@ export class ToolsetValidator { return { isValid: true, sanitized }; } + /** + * Validates and retrieves modules for a set of toolsets. + * Note: A toolset with only direct tools (no modules) is valid and returns an empty modules array. + * @param toolsetNames - Array of toolset names to validate + * @param catalog - The toolset catalog to validate against + * @returns Validation result with modules array if valid + */ public validateToolsetModules( toolsetNames: string[], catalog: ToolSetCatalog ): { isValid: boolean; modules?: string[]; error?: string } { try { - const modules = this.getModulesForToolSets(toolsetNames, catalog); - if (!modules || modules.length === 0) { - return { - isValid: false, - error: `No modules found for toolsets: ${toolsetNames.join(", ")}`, - }; + // Verify all toolset names exist in catalog first + for (const name of toolsetNames) { + if (!catalog[name]) { + return { + isValid: false, + error: `Toolset '${name}' not found in catalog`, + }; + } } + + // Get modules - empty array is valid (toolset may have only direct tools) + const modules = this.getModulesForToolSets(toolsetNames, catalog); return { isValid: true, modules }; } catch (error) { return { diff --git a/src/permissions/PermissionAwareFastifyTransport.ts b/src/permissions/PermissionAwareFastifyTransport.ts index 7268239..05b1324 100644 --- a/src/permissions/PermissionAwareFastifyTransport.ts +++ b/src/permissions/PermissionAwareFastifyTransport.ts @@ -55,7 +55,13 @@ export class PermissionAwareFastifyTransport { orchestrator: ServerOrchestrator; sessions: Map; allowedToolsets: string[]; - }>(); + failedToolsets: string[]; + }>({ + onEvict: (_key, bundle) => { + // Clean up all sessions when a client bundle is evicted + this.#cleanupBundle(bundle); + }, + }); /** * Creates a new PermissionAwareFastifyTransport instance. @@ -113,16 +119,48 @@ export class PermissionAwareFastifyTransport { } /** - * Stops the Fastify server and cleans up resources. + * Stops the Fastify server and cleans up all resources. + * Closes all client sessions and clears the cache. */ public async stop(): Promise { if (!this.app) return; + + // Stop the cache pruning interval and clear all entries (triggers cleanup) + this.clientCache.stop(true); + if (!this.options.app) { await this.app.close(); } this.app = null; } + /** + * Cleans up resources associated with a client bundle. + * Closes all sessions within the bundle. + * @param bundle - The client bundle to clean up + * @private + */ + #cleanupBundle(bundle: { + server: McpServer; + orchestrator: ServerOrchestrator; + sessions: Map; + allowedToolsets: string[]; + failedToolsets: string[]; + }): void { + for (const [sessionId, transport] of bundle.sessions.entries()) { + try { + if (typeof (transport as any).close === "function") { + (transport as any).close().catch((err: unknown) => { + console.warn(`Error closing session ${sessionId}:`, err); + }); + } + } catch (err) { + console.warn(`Error closing session ${sessionId}:`, err); + } + } + bundle.sessions.clear(); + } + /** * Normalizes the base path by removing trailing slashes. * @param basePath - The base path to normalize @@ -198,15 +236,22 @@ export class PermissionAwareFastifyTransport { if (!bundle) { try { const created = await this.createPermissionAwareBundle(context); - - // Toolsets are already loaded via async bundle creation - // No need to manually enable them - - const providedSessions = (created as any).sessions; + + // Log any failed toolsets for debugging + if (created.failedToolsets.length > 0) { + console.warn( + `Client ${context.clientId} had ${created.failedToolsets.length} toolsets fail to enable: ` + + `[${created.failedToolsets.join(", ")}]. ` + + `Successfully enabled: [${created.allowedToolsets.join(", ")}]` + ); + } + + const providedSessions = (created as { sessions?: Map }).sessions; bundle = { server: created.server, orchestrator: created.orchestrator, allowedToolsets: created.allowedToolsets, + failedToolsets: created.failedToolsets, sessions: providedSessions instanceof Map ? providedSessions : new Map(), }; diff --git a/src/permissions/PermissionResolver.ts b/src/permissions/PermissionResolver.ts index 37453b8..2967f81 100644 --- a/src/permissions/PermissionResolver.ts +++ b/src/permissions/PermissionResolver.ts @@ -7,17 +7,28 @@ import type { PermissionConfig } from "../types/index.js"; */ export class PermissionResolver { private cache = new Map(); + private readonly normalizedHeaderName: string; /** * Creates a new PermissionResolver instance. * @param config - The permission configuration defining how permissions are resolved */ - constructor(private config: PermissionConfig) {} + constructor(private config: PermissionConfig) { + // Pre-normalize header name to lowercase for case-insensitive matching + this.normalizedHeaderName = ( + config.headerName || "mcp-toolset-permissions" + ).toLowerCase(); + } /** * Resolves permissions for a client based on the configured source. * Results are cached to improve performance for subsequent requests from the same client. * Handles all errors gracefully by returning empty permissions on failure. + * + * Note on caching: For header-based permissions, permissions are cached by clientId. + * This means subsequent requests from the same client will use cached permissions, + * even if headers change. Use invalidateCache(clientId) to force re-resolution. + * * @param clientId - The unique identifier for the client * @param headers - Optional request headers (required for header-based permissions) * @returns Array of toolset names the client is allowed to access @@ -66,17 +77,34 @@ export class PermissionResolver { return permissions; } + /** + * Invalidates cached permissions for a specific client. + * Call this when you know a client's permissions have changed. + * @param clientId - The client ID to invalidate + */ + invalidateCache(clientId: string): void { + this.cache.delete(clientId); + } + /** * Parses permissions from request headers. * Extracts comma-separated toolset names from the configured header. * Handles malformed headers gracefully by returning empty permissions. + * Uses case-insensitive header lookup per RFC 7230. * @param headers - Request headers containing permission data * @returns Array of toolset names from headers, or empty array if header is missing/malformed * @private */ #parseHeaderPermissions(headers?: Record): string[] { - const headerName = this.config.headerName || "mcp-toolset-permissions"; - const headerValue = headers?.[headerName]; + if (!headers) { + return []; + } + + // Find header value using case-insensitive lookup + const headerValue = this.#findHeaderCaseInsensitive( + headers, + this.normalizedHeaderName + ); if (!headerValue) { return []; @@ -91,13 +119,38 @@ export class PermissionResolver { } catch (error) { // Handle malformed headers gracefully console.warn( - `Failed to parse permission header '${headerName}':`, + `Failed to parse permission header '${this.normalizedHeaderName}':`, error ); return []; } } + /** + * Finds a header value using case-insensitive key matching. + * HTTP headers are case-insensitive per RFC 7230. + * @param headers - The headers object to search + * @param normalizedKey - The lowercase key to search for + * @returns The header value if found, undefined otherwise + * @private + */ + #findHeaderCaseInsensitive( + headers: Record, + normalizedKey: string + ): string | undefined { + // Fast path: check if key exists as-is (common case with Fastify's lowercased headers) + if (headers[normalizedKey] !== undefined) { + return headers[normalizedKey]; + } + // Slow path: iterate and compare lowercase + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === normalizedKey) { + return value; + } + } + return undefined; + } + /** * Resolves permissions from server-side configuration. * Tries resolver function first (if provided), then falls back to static map, diff --git a/src/permissions/createPermissionAwareBundle.ts b/src/permissions/createPermissionAwareBundle.ts index 4bd7d88..8fd4de8 100644 --- a/src/permissions/createPermissionAwareBundle.ts +++ b/src/permissions/createPermissionAwareBundle.ts @@ -36,8 +36,15 @@ export interface PermissionAwareBundle { /** * The resolved permissions (allowed toolsets) for this client. + * Contains only the toolsets that were successfully enabled. */ allowedToolsets: string[]; + + /** + * Toolsets that failed to enable (e.g., invalid names). + * Empty if all requested toolsets were enabled successfully. + */ + failedToolsets: string[]; } /** @@ -67,29 +74,58 @@ export function createPermissionAwareBundle( * * @param context - Client request context containing ID and headers * @returns Promise resolving to server bundle with resolved permissions + * @throws {Error} If all requested toolsets fail to enable */ - return async (context: ClientRequestContext): Promise => { + return async ( + context: ClientRequestContext + ): Promise => { // Resolve permissions for this client - const allowedToolsets = permissionResolver.resolvePermissions( + const requestedToolsets = permissionResolver.resolvePermissions( context.clientId, context.headers ); // Create bundle with allowed toolsets (STATIC mode pre-loads them) - const bundle = originalCreateBundle(allowedToolsets); + const bundle = originalCreateBundle(requestedToolsets); // Wait for toolsets to be enabled before returning // This ensures tools are registered before the server connects to transport const manager = bundle.orchestrator.getManager(); - if (allowedToolsets.length > 0) { - await manager.enableToolsets(allowedToolsets); + + const enabledToolsets: string[] = []; + const failedToolsets: string[] = []; + + if (requestedToolsets.length > 0) { + const result = await manager.enableToolsets(requestedToolsets); + + // Collect successful and failed toolsets + for (const r of result.results) { + if (r.success) { + enabledToolsets.push(r.name); + } else { + failedToolsets.push(r.name); + console.warn( + `Failed to enable toolset '${r.name}' for client '${context.clientId}': ${r.message}` + ); + } + } + + // If ALL toolsets failed, this is likely a configuration error + if (enabledToolsets.length === 0 && failedToolsets.length > 0) { + throw new Error( + `All requested toolsets failed to enable for client '${context.clientId}'. ` + + `Requested: [${requestedToolsets.join(", ")}]. ` + + `Check that toolset names in permissions match the catalog.` + ); + } } // Return bundle with resolved permissions return { server: bundle.server, orchestrator: bundle.orchestrator, - allowedToolsets, + allowedToolsets: enabledToolsets, + failedToolsets, }; }; } diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index 8a2e86f..34390d1 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -1,5 +1,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { ExposurePolicy, Mode, ToolSetCatalog } from "../types/index.js"; +import type { + ExposurePolicy, + Mode, + ModuleLoader, + ToolSetCatalog, +} from "../types/index.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { FastifyTransport, @@ -8,13 +13,14 @@ import { export interface CreateMcpServerOptions { catalog: ToolSetCatalog; - moduleLoaders?: Record; + moduleLoaders?: Record; exposurePolicy?: ExposurePolicy; context?: unknown; startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; registerMetaTools?: boolean; http?: FastifyTransportOptions; - /** Factory to create an MCP server instance. Required. + /** + * Factory to create an MCP server instance. Required. * In DYNAMIC mode, a new instance is created per client bundle. * In STATIC mode, a single instance is created and reused across bundles. */ @@ -35,9 +41,15 @@ export async function createMcpServer(options: CreateMcpServerOptions) { }; type NotifierB = { notifyToolsListChanged: () => Promise | void }; const hasNotifierA = (s: unknown): s is NotifierA => - typeof (s as any)?.server?.notification === "function"; + typeof (s as NotifierA)?.server?.notification === "function"; const hasNotifierB = (s: unknown): s is NotifierB => - typeof (s as any)?.notifyToolsListChanged === "function"; + typeof (s as NotifierB)?.notifyToolsListChanged === "function"; + + /** + * Sends a tools list changed notification to the client. + * Logs warnings on failure instead of throwing. + * @param target - The MCP server instance + */ const notifyToolsChanged = async (target: unknown) => { try { if (hasNotifierA(target)) { @@ -49,7 +61,9 @@ export async function createMcpServer(options: CreateMcpServerOptions) { if (hasNotifierB(target)) { await target.notifyToolsListChanged(); } - } catch {} + } catch (err) { + console.warn("Failed to send tools list changed notification:", err); + } }; const orchestrator = new ServerOrchestrator({ diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index 59a0445..b3e1985 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -1,11 +1,59 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CreatePermissionBasedMcpServerOptions } from "../types/index.js"; +import type { + CreatePermissionBasedMcpServerOptions, + ExposurePolicy, +} from "../types/index.js"; import { validatePermissionConfig } from "../permissions/validatePermissionConfig.js"; import { PermissionResolver } from "../permissions/PermissionResolver.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { createPermissionAwareBundle } from "../permissions/createPermissionAwareBundle.js"; import { PermissionAwareFastifyTransport } from "../permissions/PermissionAwareFastifyTransport.js"; +/** + * Validates and sanitizes exposure policy for permission-based servers. + * Certain policy options are not applicable or could conflict with permission-based access control. + * @param policy - The original exposure policy + * @returns Sanitized policy safe for permission-based servers + * @private + */ +function sanitizeExposurePolicyForPermissions( + policy?: ExposurePolicy +): ExposurePolicy | undefined { + if (!policy) return undefined; + + const sanitized: ExposurePolicy = { + namespaceToolsWithSetKey: policy.namespaceToolsWithSetKey, + }; + + // Warn about ignored options + if (policy.allowlist !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.allowlist is ignored. " + + "Allowed toolsets are determined by client permissions." + ); + } + if (policy.denylist !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.denylist is ignored. " + + "Use permission configuration to control toolset access." + ); + } + if (policy.maxActiveToolsets !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.maxActiveToolsets is ignored. " + + "Toolset count is determined by client permissions." + ); + } + if (policy.onLimitExceeded !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.onLimitExceeded is ignored. " + + "No toolset limits are enforced." + ); + } + + return sanitized; +} + /** * Creates an MCP server with permission-based toolset access control. * @@ -92,6 +140,11 @@ export async function createPermissionBasedMcpServer( ); } + // Sanitize exposure policy for permission-based operation + const sanitizedPolicy = sanitizeExposurePolicyForPermissions( + options.exposurePolicy + ); + // Create permission resolver instance const permissionResolver = new PermissionResolver(options.permissions); @@ -104,7 +157,7 @@ export async function createPermissionBasedMcpServer( server: baseServer, catalog: options.catalog, moduleLoaders: options.moduleLoaders, - exposurePolicy: options.exposurePolicy, + exposurePolicy: sanitizedPolicy, context: options.context, notifyToolsListChanged: undefined, // No notifications in STATIC mode startup: { mode: "STATIC", toolsets: [] }, @@ -122,7 +175,7 @@ export async function createPermissionBasedMcpServer( server: clientServer, catalog: options.catalog, moduleLoaders: options.moduleLoaders, - exposurePolicy: options.exposurePolicy, + exposurePolicy: sanitizedPolicy, context: options.context, notifyToolsListChanged: undefined, // No notifications in STATIC mode startup: { mode: "STATIC", toolsets: [] }, // Empty - we'll enable manually diff --git a/src/session/ClientResourceCache.ts b/src/session/ClientResourceCache.ts index 0e0f55b..a947dc6 100644 --- a/src/session/ClientResourceCache.ts +++ b/src/session/ClientResourceCache.ts @@ -1,7 +1,14 @@ -export interface ClientResourceCacheOptions { +export interface ClientResourceCacheOptions { maxSize?: number; ttlMs?: number; // ms pruneIntervalMs?: number; + /** + * Optional cleanup callback called when a resource is removed from the cache. + * Use this to close connections, clean up sessions, etc. + * @param key - The cache key being removed + * @param resource - The resource being removed + */ + onEvict?: (key: string, resource: T) => void | Promise; } interface Entry { @@ -13,12 +20,14 @@ export class ClientResourceCache { private storage = new Map>(); private maxSize: number; private ttlMs: number; + private onEvict?: (key: string, resource: T) => void | Promise; // Use ReturnType for cross-env typings without NodeJS namespace private pruneInterval?: ReturnType; - constructor(options: ClientResourceCacheOptions = {}) { + constructor(options: ClientResourceCacheOptions = {}) { this.maxSize = options.maxSize ?? 1000; this.ttlMs = options.ttlMs ?? 1000 * 60 * 60; + this.onEvict = options.onEvict; const pruneEvery = options.pruneIntervalMs ?? 1000 * 60 * 10; this.pruneInterval = setInterval(() => this.pruneExpired(), pruneEvery); } @@ -56,17 +65,50 @@ export class ClientResourceCache { this.storage.set(key, newEntry); } + /** + * Removes an entry from the cache. + * Calls the onEvict callback if configured. + * @param key - The key to remove + */ public delete(key: string): void { - this.storage.delete(key); + const entry = this.storage.get(key); + if (entry) { + this.storage.delete(key); + this.#callEvictCallback(key, entry.resource); + } } - public stop(): void { + /** + * Stops the background pruning interval and optionally clears all entries. + * @param clearEntries - If true, also removes all entries and calls onEvict for each + */ + public stop(clearEntries = false): void { if (this.pruneInterval) { clearInterval(this.pruneInterval); this.pruneInterval = undefined; } + if (clearEntries) { + this.clear(); + } + } + + /** + * Clears all entries from the cache. + * Calls onEvict for each entry being removed. + */ + public clear(): void { + // Collect all entries first to avoid modification during iteration + const entries = Array.from(this.storage.entries()); + this.storage.clear(); + for (const [key, entry] of entries) { + this.#callEvictCallback(key, entry.resource); + } } + /** + * Evicts the least recently used entry from the cache. + * @private + */ private evictLeastRecentlyUsed(): void { const lruKey = this.storage.keys().next().value as string | undefined; if (lruKey) { @@ -74,12 +116,42 @@ export class ClientResourceCache { } } + /** + * Removes all expired entries from the cache. + * @private + */ private pruneExpired(): void { const now = Date.now(); + const keysToDelete: string[] = []; for (const [key, entry] of this.storage.entries()) { if (now - entry.lastAccessed > this.ttlMs) { - this.delete(key); + keysToDelete.push(key); + } + } + // Delete after iteration to avoid modification during iteration + for (const key of keysToDelete) { + this.delete(key); + } + } + + /** + * Safely calls the evict callback, catching and logging any errors. + * @param key - The key being evicted + * @param resource - The resource being evicted + * @private + */ + #callEvictCallback(key: string, resource: T): void { + if (!this.onEvict) return; + try { + const result = this.onEvict(key, resource); + // Handle async callbacks but don't await + if (result instanceof Promise) { + result.catch((err) => { + console.warn(`Error in cache eviction callback for key '${key}':`, err); + }); } + } catch (err) { + console.warn(`Error in cache eviction callback for key '${key}':`, err); } } } diff --git a/tests/createPermissionAwareBundle.test.ts b/tests/createPermissionAwareBundle.test.ts index 18ec525..e70b280 100644 --- a/tests/createPermissionAwareBundle.test.ts +++ b/tests/createPermissionAwareBundle.test.ts @@ -20,7 +20,14 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [ + { name: "toolset-a", success: true, message: "enabled" }, + { name: "toolset-b", success: true, message: "enabled" }, + ], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -96,7 +103,14 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [ + { name: "toolset-a", success: true, message: "enabled" }, + { name: "toolset-b", success: true, message: "enabled" }, + ], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -172,7 +186,13 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockImplementation((toolsets: string[]) => + Promise.resolve({ + success: true, + results: toolsets.map((name) => ({ name, success: true, message: "enabled" })), + message: "All toolsets enabled", + }) + ), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -211,7 +231,15 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [ + { name: "toolset-x", success: true, message: "enabled" }, + { name: "toolset-y", success: true, message: "enabled" }, + { name: "toolset-z", success: true, message: "enabled" }, + ], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -246,7 +274,14 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [ + { name: "toolset-a", success: true, message: "enabled" }, + { name: "toolset-b", success: true, message: "enabled" }, + ], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -285,7 +320,11 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [{ name: "toolset-a", success: true, message: "enabled" }], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -324,7 +363,11 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [{ name: "toolset-a", success: true, message: "enabled" }], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -358,7 +401,11 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [{ name: "toolset-a", success: true, message: "enabled" }], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), @@ -401,7 +448,11 @@ describe("createPermissionAwareBundle", () => { const { server } = createFakeMcpServer(); const mockManager = { - enableToolsets: vi.fn().mockResolvedValue({ success: true }), + enableToolsets: vi.fn().mockResolvedValue({ + success: true, + results: [{ name: "toolset-a", success: true, message: "enabled" }], + message: "All toolsets enabled", + }), }; const mockOrchestrator = { getManager: vi.fn().mockReturnValue(mockManager), diff --git a/tests/serverOrchestrator.test.ts b/tests/serverOrchestrator.test.ts index 32f357d..92b2e22 100644 --- a/tests/serverOrchestrator.test.ts +++ b/tests/serverOrchestrator.test.ts @@ -65,12 +65,13 @@ describe("ServerOrchestrator", () => { // Expect namespaced registrations for catalog tools expect(names).toContain("core.ping"); expect(names).toContain("ext.echo"); - // Meta-tools: list_tools always available; list_toolsets not in static + // Meta-tools: list_tools always available in STATIC mode expect(names).toContain("list_tools"); + // Dynamic-only meta-tools should NOT be registered in STATIC mode expect(names).not.toContain("list_toolsets"); - // Enable/disable are available in both modes - expect(names).toContain("enable_toolset"); - expect(names).toContain("disable_toolset"); + expect(names).not.toContain("describe_toolset"); + expect(names).not.toContain("enable_toolset"); + expect(names).not.toContain("disable_toolset"); }); it("ignores toolsets in DYNAMIC mode with a warning", async () => { From d1abb3a78f775f8b0e8b7d5e3647c447aa407d7d Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Fri, 28 Nov 2025 16:01:52 +0200 Subject: [PATCH 2/2] chore: bump version to 0.4.0 and audit in ci --- .github/workflows/ci.yml | 3 +++ package.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8196a63..bed2971 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Security audit + run: npm audit + - name: Typecheck run: npm run typecheck diff --git a/package.json b/package.json index a8f9ef6..6746c7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.3.0", + "version": "0.4.0", "private": false, "type": "module", "main": "dist/index.js",