Skip to content

Commit d5a6822

Browse files
authored
Merge pull request #21 from code-rabi/benr/feat-safe-meta-tools-registration
Benr/feat safe meta tools registration
2 parents 2714dd0 + 4755f83 commit d5a6822

8 files changed

Lines changed: 168 additions & 13 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "toolception",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"private": false,
55
"type": "module",
66
"main": "dist/index.js",

‎src/core/CLAUDE.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset
3838

3939
Located in `src/meta/registerMetaTools.ts` (called by ServerOrchestrator):
4040

41+
Meta-tools are registered with `ToolRegistry` under the reserved `_meta` toolset key (`META_TOOLSET_KEY` constant). This ensures collision detection with user-defined tools and makes meta-tools visible in `toolRegistry.list()` and `toolRegistry.listByToolset()`.
42+
4143
**DYNAMIC mode only:**
4244
- `enable_toolset` / `disable_toolset` - Runtime toolset management
4345
- `list_toolsets` / `describe_toolset` - Discovery
4446

4547
**Both modes:**
46-
- `list_tools` - List registered tool names
48+
- `list_tools` - List registered tool names (includes meta-tools)
4749

4850
## Anti-patterns
4951

5052
- Bypassing ToolRegistry for tool registration (causes collision issues)
5153
- Expecting disable to unregister tools from MCP (it can't)
5254
- Throwing on notification failures (they're expected in SSE disconnect)
55+
- Using `_meta` as a toolset key in the catalog (reserved for meta-tools, rejected at startup)
5356

5457
## Enable Toolset Flow
5558

‎src/core/ServerOrchestrator.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class ServerOrchestrator {
5454

5555
// Register meta-tools only if requested (default true)
5656
if (options.registerMetaTools !== false) {
57-
registerMetaTools(options.server, this.manager, { mode: this.mode });
57+
registerMetaTools(options.server, this.manager, toolRegistry, { mode: this.mode });
5858
}
5959

6060
// Startup behavior - store promise for async initialization

‎src/meta/registerMetaTools.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import type { Mode } from "../types/index.js";
33
import { z } from "zod";
44
import { DynamicToolManager } from "../core/DynamicToolManager.js";
5+
import { ToolRegistry } from "../core/ToolRegistry.js";
6+
7+
/**
8+
* Reserved toolset key for meta-tools.
9+
* Meta-tools are registered under this key to enable collision detection
10+
* and tracking via the ToolRegistry.
11+
*/
12+
export const META_TOOLSET_KEY = "_meta";
513

614
/**
715
* Registers meta-tools on the MCP server for toolset management.
@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
1321
*
1422
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
1523
*
24+
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
25+
* to enable collision detection with user-defined tools.
26+
*
1627
* @param server - The MCP server to register tools on
1728
* @param manager - The DynamicToolManager instance
29+
* @param toolRegistry - The ToolRegistry for collision detection
1830
* @param options - Configuration options including the mode
1931
*/
2032
export function registerMetaTools(
2133
server: McpServer,
2234
manager: DynamicToolManager,
35+
toolRegistry: ToolRegistry,
2336
options?: { mode?: Exclude<Mode, "ALL"> }
2437
): void {
2538
const mode = options?.mode ?? "DYNAMIC";
2639

2740
// Dynamic-mode only tools: enable/disable toolsets at runtime
2841
if (mode === "DYNAMIC") {
42+
// Register with ToolRegistry for collision detection before server.tool()
43+
toolRegistry.addForToolset(META_TOOLSET_KEY, "enable_toolset");
2944
server.tool(
3045
"enable_toolset",
3146
"Enable a toolset by name",
@@ -39,6 +54,7 @@ export function registerMetaTools(
3954
}
4055
);
4156

57+
toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
4258
server.tool(
4359
"disable_toolset",
4460
"Disable a toolset by name (state only)",
@@ -52,6 +68,7 @@ export function registerMetaTools(
5268
}
5369
);
5470

71+
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
5572
server.tool(
5673
"list_toolsets",
5774
"List available toolsets with active status and definitions",
@@ -84,6 +101,7 @@ export function registerMetaTools(
84101
}
85102
);
86103

104+
toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
87105
server.tool(
88106
"describe_toolset",
89107
"Describe a toolset with definition, active status and tools",
@@ -121,6 +139,7 @@ export function registerMetaTools(
121139
}
122140

123141
// list_tools is available in both modes
142+
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
124143
server.tool(
125144
"list_tools",
126145
"List currently registered tool names (best effort)",

‎src/mode/ModuleResolver.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import type {
55
ModuleLoader,
66
} from "../types/index.js";
77

8+
/**
9+
* Reserved toolset keys that cannot be used in user catalogs.
10+
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
11+
*/
12+
const RESERVED_TOOLSET_KEYS = ["_meta"];
13+
814
export interface ModuleResolverOptions {
915
catalog: ToolSetCatalog;
1016
moduleLoaders?: Record<string, ModuleLoader>;
@@ -15,6 +21,14 @@ export class ModuleResolver {
1521
private readonly moduleLoaders: Record<string, ModuleLoader>;
1622

1723
constructor(options: ModuleResolverOptions) {
24+
// Validate catalog doesn't use reserved keys
25+
for (const key of RESERVED_TOOLSET_KEYS) {
26+
if (key in options.catalog) {
27+
throw new Error(
28+
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
29+
);
30+
}
31+
}
1832
this.catalog = options.catalog;
1933
this.moduleLoaders = options.moduleLoaders ?? {};
2034
}
@@ -49,6 +63,13 @@ export class ModuleResolver {
4963
)}`,
5064
};
5165
}
66+
// Check for reserved keys (defense in depth)
67+
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
68+
return {
69+
isValid: false,
70+
error: `Toolset key '${sanitized}' is reserved for internal use`,
71+
};
72+
}
5273
if (!this.catalog[sanitized]) {
5374
return {
5475
isValid: false,

‎src/types/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export type ToolingErrorCode =
5353
| "E_NOTIFY_FAILED"
5454
| "E_INTERNAL";
5555

56-
// Module loader API: returns tools contributed by a module
5756
// Module loader API: returns tools contributed by a module.
5857
// Loaders may ignore the context argument if not needed.
5958
export type ModuleLoader = (

‎tests/metaTools.test.ts‎

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
2+
import { registerMetaTools, META_TOOLSET_KEY } from "../src/meta/registerMetaTools.js";
33
import { DynamicToolManager } from "../src/core/DynamicToolManager.js";
44
import { ModuleResolver } from "../src/mode/ModuleResolver.js";
55
import { ToolRegistry } from "../src/core/ToolRegistry.js";
@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
4949
resolver,
5050
toolRegistry,
5151
});
52-
registerMetaTools(server, manager, { mode: "DYNAMIC" });
53-
return { server, tools, manager };
52+
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
53+
return { server, tools, manager, toolRegistry };
5454
}
5555

5656
function findTool(tools: RegisteredTool[], name: string): RegisteredTool | undefined {
@@ -66,17 +66,23 @@ describe("Meta-tools return formats", () => {
6666
}
6767

6868
describe("list_tools", () => {
69-
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
69+
it("returns meta-tools when no user toolsets enabled", async () => {
7070
const { tools } = createTestSetup();
7171
const result = await callTool(tools, "list_tools");
7272

7373
expect(result).toHaveProperty("tools");
7474
expect(result).toHaveProperty("toolsetToTools");
7575
expect(Array.isArray(result.tools)).toBe(true);
7676
expect(typeof result.toolsetToTools).toBe("object");
77-
// No toolsets enabled, so no user tools (only meta-tools not tracked)
78-
expect(result.tools).toEqual([]);
79-
expect(result.toolsetToTools).toEqual({});
77+
// Meta-tools are now tracked in the registry
78+
expect(result.tools).toContain("enable_toolset");
79+
expect(result.tools).toContain("disable_toolset");
80+
expect(result.tools).toContain("list_toolsets");
81+
expect(result.tools).toContain("describe_toolset");
82+
expect(result.tools).toContain("list_tools");
83+
// Meta-tools appear under _meta key
84+
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
85+
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
8086
});
8187

8288
it("returns correct structure after enabling toolsets", async () => {
@@ -236,9 +242,10 @@ describe("Meta-tools return formats", () => {
236242
it("only registers list_tools in STATIC mode", () => {
237243
const { server, tools } = createFakeMcpServer();
238244
const resolver = new ModuleResolver({ catalog });
239-
const manager = new DynamicToolManager({ server, resolver });
245+
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
246+
const manager = new DynamicToolManager({ server, resolver, toolRegistry });
240247

241-
registerMetaTools(server, manager, { mode: "STATIC" });
248+
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });
242249

243250
const toolNames = tools.map((t) => t.name);
244251
expect(toolNames).toContain("list_tools");
@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
247254
expect(toolNames).not.toContain("list_toolsets");
248255
expect(toolNames).not.toContain("describe_toolset");
249256
});
257+
258+
it("registers list_tools in ToolRegistry in STATIC mode", () => {
259+
const { server } = createFakeMcpServer();
260+
const resolver = new ModuleResolver({ catalog });
261+
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
262+
const manager = new DynamicToolManager({ server, resolver, toolRegistry });
263+
264+
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });
265+
266+
expect(toolRegistry.has("list_tools")).toBe(true);
267+
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
268+
});
269+
});
270+
271+
describe("ToolRegistry integration", () => {
272+
it("meta-tools appear in toolRegistry.list()", () => {
273+
const { toolRegistry } = createTestSetup();
274+
275+
const registeredTools = toolRegistry.list();
276+
expect(registeredTools).toContain("enable_toolset");
277+
expect(registeredTools).toContain("disable_toolset");
278+
expect(registeredTools).toContain("list_toolsets");
279+
expect(registeredTools).toContain("describe_toolset");
280+
expect(registeredTools).toContain("list_tools");
281+
});
282+
283+
it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
284+
const { toolRegistry } = createTestSetup();
285+
286+
const byToolset = toolRegistry.listByToolset();
287+
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
288+
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
289+
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
290+
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
291+
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
292+
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
293+
});
294+
295+
it("returns collision error when user tool collides with meta-tool", async () => {
296+
const { server } = createFakeMcpServer();
297+
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
298+
const resolver = new ModuleResolver({
299+
catalog: {
300+
conflict: {
301+
name: "Conflict",
302+
description: "Toolset with conflicting tool name",
303+
tools: [
304+
{
305+
name: "enable_toolset", // Collides with meta-tool
306+
description: "A user tool",
307+
inputSchema: { type: "object", properties: {} },
308+
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
309+
},
310+
],
311+
},
312+
},
313+
});
314+
const manager = new DynamicToolManager({ server, resolver, toolRegistry });
315+
316+
// Register meta-tools first
317+
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
318+
319+
// Enabling a toolset with a conflicting tool name returns failure with collision message
320+
const result = await manager.enableToolset("conflict");
321+
expect(result.success).toBe(false);
322+
expect(result.message).toMatch(/collision/i);
323+
});
324+
325+
it("cannot register tool named enable_toolset after meta-tools registered", () => {
326+
const { toolRegistry } = createTestSetup();
327+
328+
// Attempting to add a tool with the same name should throw
329+
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
330+
});
250331
});
251332
});

‎tests/moduleResolver.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
158158
expect(tools.map((t) => t.name)).toContain("sync_tool");
159159
});
160160
});
161+
162+
describe("reserved toolset keys", () => {
163+
it("throws when catalog contains _meta key", () => {
164+
expect(() => new ModuleResolver({
165+
catalog: {
166+
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
167+
} as any,
168+
})).toThrow(/reserved for internal use/);
169+
});
170+
171+
it("validateToolsetName rejects _meta as reserved", () => {
172+
const r = new ModuleResolver({
173+
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
174+
});
175+
176+
const result = r.validateToolsetName("_meta");
177+
expect(result.isValid).toBe(false);
178+
expect(result.error).toMatch(/reserved for internal use/);
179+
});
180+
181+
it("allows normal toolset keys", () => {
182+
const r = new ModuleResolver({
183+
catalog: {
184+
core: { name: "Core", description: "", tools: [] },
185+
"my-toolset": { name: "My Toolset", description: "", tools: [] },
186+
} as any,
187+
});
188+
189+
expect(r.validateToolsetName("core").isValid).toBe(true);
190+
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
191+
});
192+
});
161193
});

0 commit comments

Comments
 (0)