Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.6.0",
"version": "0.6.1",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
5 changes: 4 additions & 1 deletion src/core/CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,18 +38,21 @@ Central orchestration layer that wires together all components. Manages toolset

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

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()`.

**DYNAMIC mode only:**
- `enable_toolset` / `disable_toolset` - Runtime toolset management
- `list_toolsets` / `describe_toolset` - Discovery

**Both modes:**
- `list_tools` - List registered tool names
- `list_tools` - List registered tool names (includes meta-tools)

## Anti-patterns

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

## Enable Toolset Flow

Expand Down
2 changes: 1 addition & 1 deletion src/core/ServerOrchestrator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ export class ServerOrchestrator {

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

// Startup behavior - store promise for async initialization
Expand Down
19 changes: 19 additions & 0 deletions src/meta/registerMetaTools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,14 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Mode } from "../types/index.js";
import { z } from "zod";
import { DynamicToolManager } from "../core/DynamicToolManager.js";
import { ToolRegistry } from "../core/ToolRegistry.js";

/**
* Reserved toolset key for meta-tools.
* Meta-tools are registered under this key to enable collision detection
* and tracking via the ToolRegistry.
*/
export const META_TOOLSET_KEY = "_meta";
Comment thread
imbenrabi marked this conversation as resolved.

/**
* Registers meta-tools on the MCP server for toolset management.
Expand All@@ -13,19 +21,26 @@ import { DynamicToolManager } from "../core/DynamicToolManager.js";
*
* In STATIC mode, only list_tools is registered since toolsets are fixed at startup.
*
* Meta-tools are registered with the ToolRegistry under the reserved "_meta" toolset key
* to enable collision detection with user-defined tools.
*
* @param server - The MCP server to register tools on
* @param manager - The DynamicToolManager instance
* @param toolRegistry - The ToolRegistry for collision detection
* @param options - Configuration options including the mode
*/
export function registerMetaTools(
server: McpServer,
manager: DynamicToolManager,
toolRegistry: ToolRegistry,
options?: { mode?: Exclude<Mode, "ALL"> }
): void {
const mode = options?.mode ?? "DYNAMIC";

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

toolRegistry.addForToolset(META_TOOLSET_KEY, "disable_toolset");
server.tool(
"disable_toolset",
"Disable a toolset by name (state only)",
Expand All@@ -52,6 +68,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "list_toolsets");
server.tool(
"list_toolsets",
"List available toolsets with active status and definitions",
Expand DownExpand Up@@ -84,6 +101,7 @@ export function registerMetaTools(
}
);

toolRegistry.addForToolset(META_TOOLSET_KEY, "describe_toolset");
server.tool(
"describe_toolset",
"Describe a toolset with definition, active status and tools",
Expand DownExpand Up@@ -121,6 +139,7 @@ export function registerMetaTools(
}

// list_tools is available in both modes
toolRegistry.addForToolset(META_TOOLSET_KEY, "list_tools");
server.tool(
"list_tools",
"List currently registered tool names (best effort)",
Expand Down
21 changes: 21 additions & 0 deletions src/mode/ModuleResolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ import type {
ModuleLoader,
} from "../types/index.js";

/**
* Reserved toolset keys that cannot be used in user catalogs.
* Must match META_TOOLSET_KEY in src/meta/registerMetaTools.ts
*/
const RESERVED_TOOLSET_KEYS = ["_meta"];

export interface ModuleResolverOptions {
catalog: ToolSetCatalog;
moduleLoaders?: Record<string, ModuleLoader>;
Expand All@@ -15,6 +21,14 @@ export class ModuleResolver {
private readonly moduleLoaders: Record<string, ModuleLoader>;

constructor(options: ModuleResolverOptions) {
// Validate catalog doesn't use reserved keys
for (const key of RESERVED_TOOLSET_KEYS) {
if (key in options.catalog) {
throw new Error(
`Toolset key '${key}' is reserved for internal use and cannot be used in the catalog`
);
}
}
this.catalog = options.catalog;
this.moduleLoaders = options.moduleLoaders ?? {};
}
Expand DownExpand Up@@ -49,6 +63,13 @@ export class ModuleResolver {
)}`,
};
}
// Check for reserved keys (defense in depth)
if (RESERVED_TOOLSET_KEYS.includes(sanitized)) {
return {
isValid: false,
error: `Toolset key '${sanitized}' is reserved for internal use`,
};
}
if (!this.catalog[sanitized]) {
return {
isValid: false,
Expand Down
1 change: 0 additions & 1 deletion src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,6 @@ export type ToolingErrorCode =
| "E_NOTIFY_FAILED"
| "E_INTERNAL";

// Module loader API: returns tools contributed by a module
// Module loader API: returns tools contributed by a module.
// Loaders may ignore the context argument if not needed.
export type ModuleLoader = (
Expand Down
99 changes: 90 additions & 9 deletions tests/metaTools.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerMetaTools } from "../src/meta/registerMetaTools.js";
import { registerMetaTools, META_TOOLSET_KEY } 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";
Expand DownExpand Up@@ -49,8 +49,8 @@ describe("Meta-tools return formats", () => {
resolver,
toolRegistry,
});
registerMetaTools(server, manager, { mode: "DYNAMIC" });
return { server, tools, manager };
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });
return { server, tools, manager, toolRegistry };
}

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

describe("list_tools", () => {
it("returns { tools: [], toolsetToTools: {} } when no toolsets enabled", async () => {
it("returns meta-toolswhen no user 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({});
// Meta-tools are now tracked in the registry
expect(result.tools).toContain("enable_toolset");
expect(result.tools).toContain("disable_toolset");
expect(result.tools).toContain("list_toolsets");
expect(result.tools).toContain("describe_toolset");
expect(result.tools).toContain("list_tools");
// Meta-tools appear under _meta key
expect(result.toolsetToTools[META_TOOLSET_KEY]).toBeDefined();
expect(result.toolsetToTools[META_TOOLSET_KEY]).toContain("list_tools");
});

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

registerMetaTools(server, manager, { mode: "STATIC" });
registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("list_tools");
Expand All@@ -247,5 +254,79 @@ describe("Meta-tools return formats", () => {
expect(toolNames).not.toContain("list_toolsets");
expect(toolNames).not.toContain("describe_toolset");
});

it("registers list_tools in ToolRegistry in STATIC mode", () => {
const { server } = createFakeMcpServer();
const resolver = new ModuleResolver({ catalog });
const toolRegistry = new ToolRegistry({ namespaceWithToolset: true });
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

registerMetaTools(server, manager, toolRegistry, { mode: "STATIC" });

expect(toolRegistry.has("list_tools")).toBe(true);
expect(toolRegistry.listByToolset()[META_TOOLSET_KEY]).toContain("list_tools");
});
});

describe("ToolRegistry integration", () => {
it("meta-tools appear in toolRegistry.list()", () => {
const { toolRegistry } = createTestSetup();

const registeredTools = toolRegistry.list();
expect(registeredTools).toContain("enable_toolset");
expect(registeredTools).toContain("disable_toolset");
expect(registeredTools).toContain("list_toolsets");
expect(registeredTools).toContain("describe_toolset");
expect(registeredTools).toContain("list_tools");
});

it("meta-tools appear in toolRegistry.listByToolset() under _meta key", () => {
const { toolRegistry } = createTestSetup();

const byToolset = toolRegistry.listByToolset();
expect(byToolset[META_TOOLSET_KEY]).toBeDefined();
expect(byToolset[META_TOOLSET_KEY]).toContain("enable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("disable_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_toolsets");
expect(byToolset[META_TOOLSET_KEY]).toContain("describe_toolset");
expect(byToolset[META_TOOLSET_KEY]).toContain("list_tools");
});

it("returns collision error when user tool collides with meta-tool", async () => {
const { server } = createFakeMcpServer();
const toolRegistry = new ToolRegistry({ namespaceWithToolset: false }); // No namespacing to force collision
const resolver = new ModuleResolver({
catalog: {
conflict: {
name: "Conflict",
description: "Toolset with conflicting tool name",
tools: [
{
name: "enable_toolset", // Collides with meta-tool
description: "A user tool",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ content: [{ type: "text", text: "user" }] }),
},
],
},
},
});
const manager = new DynamicToolManager({ server, resolver, toolRegistry });

// Register meta-tools first
registerMetaTools(server, manager, toolRegistry, { mode: "DYNAMIC" });

// Enabling a toolset with a conflicting tool name returns failure with collision message
const result = await manager.enableToolset("conflict");
expect(result.success).toBe(false);
expect(result.message).toMatch(/collision/i);
});

it("cannot register tool named enable_toolset after meta-tools registered", () => {
const { toolRegistry } = createTestSetup();

// Attempting to add a tool with the same name should throw
expect(() => toolRegistry.add("enable_toolset")).toThrow(/collision/i);
});
});
});
32 changes: 32 additions & 0 deletions tests/moduleResolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,4 +158,36 @@ describe("ModuleResolver", () => {
expect(tools.map((t) => t.name)).toContain("sync_tool");
});
});

describe("reserved toolset keys", () => {
it("throws when catalog contains _meta key", () => {
expect(() => new ModuleResolver({
catalog: {
_meta: { name: "Meta", description: "User-defined meta", tools: [] },
} as any,
})).toThrow(/reserved for internal use/);
});

it("validateToolsetName rejects _meta as reserved", () => {
const r = new ModuleResolver({
catalog: { core: { name: "Core", description: "", tools: [] } } as any,
});

const result = r.validateToolsetName("_meta");
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/reserved for internal use/);
});

it("allows normal toolset keys", () => {
const r = new ModuleResolver({
catalog: {
core: { name: "Core", description: "", tools: [] },
"my-toolset": { name: "My Toolset", description: "", tools: [] },
} as any,
});

expect(r.validateToolsetName("core").isValid).toBe(true);
expect(r.validateToolsetName("my-toolset").isValid).toBe(true);
});
});
});