Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
61dafea
fix: mark enable_toolset and disable_toolset as non-destructive
claude Oct 10, 2026
13c4887
fix: wait for the orchestrator before connecting a permission-aware c…
claude Oct 10, 2026
f1becfd
docs: say repeat toggles have no further effect on state
claude Oct 10, 2026
cfa334c
feat(http): validate Origin on /mcp endpoints via http.allowedOrigins
claude Oct 10, 2026
4ec564a
fix: disable_toolset unregisters the toolset's tools
claude Oct 10, 2026
20e7ba2
fix(http): reject STATIC clients whose startup toolsets failed to load
claude Oct 10, 2026
74a1ad0
fix(http): address TS review round 1 on Origin validation
claude Oct 10, 2026
0b4a411
review: tighten removal types and tests
claude Oct 10, 2026
d73df0d
docs(http): fix Origin guard TSDoc and lower-case non-http hosts
claude Oct 10, 2026
eb3411b
fix(core): only count failed tool registration as a startup failure
claude Oct 10, 2026
cbdd1a3
docs: clarify the registration-failure code and the Breaking changelo…
claude Oct 10, 2026
3e303f7
Merge remote-tracking branch 'origin/claude/project-thread-7cs1pg' in…
claude Oct 10, 2026
3ba9cb1
Merge remote-tracking branch 'origin/claude/project-thread-7rn4lq' in…
claude Oct 10, 2026
fe5e062
Merge remote-tracking branch 'origin/claude/project-thread-i2z3lz' in…
claude Oct 10, 2026
a8ed7c1
chore(release): 0.8.0 changelog and version
claude Oct 10, 2026
a2857cf
fix(permissions): reject permission-aware clients whose startup tools…
claude Oct 10, 2026
0349957
docs: scope the 0.8.0 failed-startup notes to createMcpServer
claude Oct 10, 2026
1c58bb8
review: tighten permission-factory assertions, call #30 internal hard…
claude Oct 10, 2026
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ These are cross-cutting; see leaf Intent Nodes for module-specific invariants.
1. **All tools → ToolRegistry** — Collision detection happens here only
2. **`mcp-client-id` required for /mcp** — POST, GET, DELETE all reject without header (400)
3. **Fail-secure** — Invalid inputs return empty objects/arrays, not errors
4. **Silent module failures** — Toolsets activate with partial tools if loaders fail
5. **Disable is state-only** — MCP SDK has no tool unregister; see `src/core/AGENTS.md`
4. **Module loader errors are silent, registration failures are not** — A throwing loader is logged and the toolset activates with fewer tools. A failure while registering a startup toolset's tools (for example a duplicate tool name) makes `isReady()` return `false`: STATIC `createMcpServer` throws at startup and its transport answers the client's `initialize` with a 500 / `-32603` and drops the bundle. `createPermissionBasedMcpServer` is the exception: it enables toolsets in its bundle factory and continues on partial failure
5. **Disable unregisters tools** — `disableToolset` removes the toolset's tools from the MCP server via the SDK's `RegisteredTool.remove()`; see `src/core/AGENTS.md`

## Module Index

Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to `toolception` are listed here. Versions follow [semver](h

Releases before 0.7.0 are described in [GitHub Releases](https://github.com/code-rabi/toolception/releases).

## [0.8.0]

### Breaking

- A STATIC client of `createMcpServer` whose startup toolsets fail to register their tools now gets a JSON-RPC error (`-32603`, HTTP 500, "Failed to load toolsets for this client.") on `initialize` instead of connecting with only some of its tools. The failed bundle is dropped, so the client's next `initialize` retries the load. ([#33](https://github.com/code-rabi/toolception/pull/33))
- `ServerOrchestrator.isReady()` now returns `false`, and `ensureReady()` throws, when registering a startup toolset's tools fails (for example a duplicate tool name, within a toolset or across toolsets when `namespaceToolsWithSetKey` is off). It used to report ready. `createMcpServer` therefore rejects at startup for such a catalog in STATIC mode, where it used to start with the partial toolset. `enableToolset` results (and the `enable_toolset` meta-tool output) gain an optional `code` on that failure.
- `createPermissionBasedMcpServer` is not affected: it enables each client's permitted toolsets itself, still connects a client when only some of them fail (`failedToolsets`), and still answers 403 when all of them fail.
- Toolsets refused before registration (exposure policy, duplicate names in `startup.toolsets`) and module loader errors do not count as failures.
- **Migration:** before upgrading, make sure no two startup toolsets register the same tool name (rename or remove the duplicate, or turn on `namespaceToolsWithSetKey`). A catalog that only worked because the collision was swallowed will now fail `initialize`, or fail at startup in STATIC mode.

### Added

- `http.allowedOrigins` validates the `Origin` header on the `/mcp` endpoints, as the MCP spec requires. A request with an `Origin` outside the list gets `403`; requests without an `Origin` header are always allowed. When `cors` is on, only the listed origins get CORS access. Not set by default, so existing deployments behave as before. ([#32](https://github.com/code-rabi/toolception/pull/32))
- **Migration:** none needed. To turn it on, pass `http: { allowedOrigins: ["https://app.example.com"] }`.

### Changed

- `disable_toolset` now removes the toolset's tools from the MCP server, so clients no longer list or call them, and the toolset can be enabled again. Before, it only changed the toolset's state and the tools stayed callable. The disable message ends with `Removed N tools.`; a partly failed enable also rolls back the tools it had registered. Clients get one `tools/list_changed` per removed tool plus the batched one. ([#31](https://github.com/code-rabi/toolception/pull/31))
- **Migration:** code that relied on a disabled toolset's tools staying callable must stop doing so.
- `enable_toolset` and `disable_toolset` are advertised with `destructiveHint: false, idempotentHint: true`. They only change which tools are exposed, so clients no longer need to treat them as destructive. ([#29](https://github.com/code-rabi/toolception/pull/29))

### Fixed

- `PermissionAwareFastifyTransport` waits for the client's orchestrator to be ready before connecting and, like `FastifyTransport`, answers `500` / `-32603` and drops the bundle when it is not ready. Internal hardening: the class is not part of the public API and `createPermissionBasedMcpServer` already loaded toolsets before returning, so its behaviour is unchanged. ([#30](https://github.com/code-rabi/toolception/pull/30))

## [0.7.0]

### Breaking
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ Notes
- Policy is enforced at enable time (via meta-tools or static preload).
- If both `allowlist` and `denylist` are present, the entry must be in `allowlist` and not in `denylist` to pass.
- Namespacing is applied consistently at registration time and reflected in `GET /tools`.
- A startup toolset whose tools fail to register (for example a duplicate tool name, within a toolset or across toolsets when `namespaceToolsWithSetKey` is off) is a startup failure, not a partial start: `createMcpServer` in STATIC mode rejects at startup, and over HTTP the client's `initialize` returns a JSON-RPC `-32603` error (HTTP 500) and is retried on its next `initialize`. A module loader that throws is only logged, and its toolset activates with fewer tools.

#### options.context (optional)

Expand Down Expand Up @@ -962,9 +963,14 @@ Notes

#### options.http (optional)

`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; customEndpoints?: CustomEndpointDefinition[] }`
`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; allowedOrigins?: string[]; customEndpoints?: CustomEndpointDefinition[] }`

- Fastify transport configuration. Defaults: host `0.0.0.0`, port `3000`, basePath `/`, CORS enabled, logger disabled.
- `allowedOrigins`: Origins allowed to call `/mcp` (POST, GET, DELETE), e.g. `["https://app.example.com"]`. The MCP spec requires servers to validate `Origin` as a defence against DNS rebinding, so set this on any server that browsers could reach, and in particular on one bound to localhost.
- Not set (default): no check, as before. Set: a request whose `Origin` is not listed gets `403`.
- Requests with no `Origin` header (SDK, CLI and server-to-server clients) are always allowed, so non-browser clients keep working. `[]` allows only those.
- Entries are compared by scheme, host and port, case-insensitively. An entry that is not an origin (such as `"*"`) throws at startup. The request's `Origin` must be a bare origin as browsers send it, so a value with a path, userinfo, query or explicit default port is refused.
- With `cors` on, only the listed origins also get CORS access; the other endpoints (`/healthz`, `/tools`, custom endpoints) are not Origin-checked.
- `customEndpoints`: Optional array of custom HTTP endpoints to register alongside MCP protocol endpoints. See [Custom HTTP endpoints](#custom-http-endpoints) for details.

#### options.createServer (optional)
Expand Down Expand Up @@ -1081,7 +1087,7 @@ Meta-tools are registered based on mode:
**DYNAMIC mode** (registered by default, or when `registerMetaTools` is true):

- `enable_toolset` - Enable a toolset by name
- `disable_toolset` - Disable a toolset by name (state only; tools remain registered)
- `disable_toolset` - Disable a toolset by name (unregisters the toolset's tools, so clients can no longer list or call them)
- `list_toolsets` - List available toolsets with active status
- `describe_toolset` - Describe a specific toolset with definition and tools
- `list_tools` - List currently registered tool names
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.7.0",
"version": "0.8.0",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
9 changes: 5 additions & 4 deletions src/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ Central orchestration layer. Manages toolset lifecycle, tool registration, and s
## Invariants

1. **All tools go through ToolRegistry** — Collision detection happens here only. Bypassing it causes silent name conflicts.
2. **Disable is state-only** — MCP SDK has no `server.removeTool()`; disabled toolsets' tools remain callable by clients. `activeToolsets` tracks logical state only.
2. **Disable unregisters the tools** — `DynamicToolManager` keeps the `RegisteredTool` handle `registerTool()` returns for each tool and calls `remove()` on disable, then frees the names in ToolRegistry (`removeForToolset`) so the toolset can be enabled again. Disabled tools are neither listed nor callable.
3. **Notifications may fail silently** — "Not connected" errors are expected during SSE disconnect and are swallowed.
4. **Namespacing applied BEFORE collision check** — `mapAndValidate()` generates safe names first, then checks for conflicts.
5. **Toolset added to activeToolsets AFTER registration** — All tools must register successfully before toolset is marked active. Partial failures leave the toolset inactive but orphaned tools remain registered (MCP limitation).
6. **`enableToolsets()` batches notifications** — Calls `enableToolset(name, skipNotification=true)` per toolset, sends one `tools/list_changed` notification at the end.
5. **Toolset added to activeToolsets AFTER registration** — All tools must register successfully before toolset is marked active. A partial failure removes the tools already registered, so nothing is left callable and the toolset can be enabled again.
6. **`enableToolsets()` batches notifications** — Calls `enableToolset(name, skipNotification=true)` per toolset, sends one `tools/list_changed` notification at the end. The SDK also sends its own `tools/list_changed` for every `registerTool()` and `remove()` call on a connected server, so a disable of N tools sends N+1.
7. **JSON Schema `inputSchema` uses `registerTool` with a passthrough object** — The SDK rejects plain JSON Schema as a tool schema. Zod raw shapes and schemas are passed to `registerTool` as is. Clients see an open object schema for JSON Schema tools, not the declared one.
8. **Failed tool registration at startup makes the orchestrator not ready** — `enableToolsets()` reports failures in its result and never throws. A failure that carries a `code` (set when registering the toolset's tools failed, e.g. a tool name collision) becomes `initError`, so `isReady()` is `false` and `ensureReady()` throws. Refusals made before any registration (invalid name, already enabled, exposure policy) carry no code and are ignored. Module loader errors are also ignored: `ModuleResolver` logs them and continues.

## Enable Toolset Flow

Expand All @@ -38,7 +39,7 @@ Tools are registered with `McpServer.registerTool` (the 5-arg `server.tool()` ov
## Anti-patterns

- Bypassing ToolRegistry for tool registration (causes collision issues)
- Expecting disable to unregister tools from MCP (it can't)
- Registering tools without keeping the `registerTool()` handle (disable and failed-enable rollback can't remove them)
- Throwing on notification failures (they're expected in SSE disconnect)
- Using `_meta` as a toolset key in the catalog (reserved for meta-tools — see `src/meta/AGENTS.md`)

Expand Down
62 changes: 33 additions & 29 deletions src/core/DynamicToolManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
McpServer,
RegisteredTool,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
import type {
Expand All @@ -7,6 +10,7 @@ import type {
ToolSetDefinition,
ToolingErrorCode,
} from "../types/index.js";
import { ToolingError } from "../errors/ToolingError.js";
import { ModuleResolver } from "../mode/ModuleResolver.js";
import { ToolRegistry } from "./ToolRegistry.js";
import type { DynamicToolManagerOptions } from "./core.types.js";
Expand All @@ -20,6 +24,8 @@ export class DynamicToolManager {
private readonly toolRegistry: ToolRegistry;

private readonly activeToolsets = new Set<string>();
/** SDK handles of each toolset's registered tools, used to unregister them. */
private readonly toolHandles = new Map<string, readonly RegisteredTool[]>();

constructor(options: DynamicToolManagerOptions) {
this.server = options.server;
Expand Down Expand Up @@ -77,12 +83,12 @@ export class DynamicToolManager {
* Enables a single toolset by name.
* @param toolsetName - The name of the toolset to enable
* @param skipNotification - If true, skips the tool list change notification
* @returns Result object with success status and message
* @returns Result object with success status and message; `code` is set only when registering the toolset's tools failed
*/
public async enableToolset(
toolsetName: string,
skipNotification = false
): Promise<{ success: boolean; message: string }> {
): Promise<{ success: boolean; message: string; code?: ToolingErrorCode }> {
const validation = this.validateToolsetForEnable(toolsetName);
if ("message" in validation) return validation;

Expand All @@ -94,11 +100,8 @@ export class DynamicToolManager {
return { success: false, message: policyCheck.message };
}

// Track tools registered for this enable operation to allow rollback
const registeredTools: string[] = [];

try {
const toolCount = await this.resolveAndRegisterTools(sanitized, registeredTools);
const toolCount = await this.resolveAndRegisterTools(sanitized);

// Track state only after successful registration
this.activeToolsets.add(sanitized);
Expand All @@ -110,12 +113,14 @@ export class DynamicToolManager {

return this.buildEnableResult(sanitized, toolCount);
} catch (error) {
this.handlePartialFailure(sanitized, registeredTools);
this.removeToolsetTools(sanitized);
return {
success: false,
message: `Failed to enable toolset '${sanitized}': ${
error instanceof Error ? error.message : "Unknown error"
}`,
// Only this path sets a code; ServerOrchestrator treats it as "failed while registering"
code: error instanceof ToolingError ? error.code : "E_INTERNAL",
};
}
}
Expand Down Expand Up @@ -145,13 +150,9 @@ export class DynamicToolManager {

/**
* @param sanitized - The validated toolset name
* @param registeredTools - Mutable array tracking registered tool names for rollback
* @returns The number of tools resolved
*/
private async resolveAndRegisterTools(
sanitized: string,
registeredTools: string[]
): Promise<number> {
private async resolveAndRegisterTools(sanitized: string): Promise<number> {
const resolvedTools = await this.resolver.resolveToolsForToolsets(
[sanitized],
this.context
Expand All @@ -161,7 +162,6 @@ export class DynamicToolManager {
const mapped = this.toolRegistry.mapAndValidate(sanitized, resolvedTools);
for (const tool of mapped) {
this.registerSingleTool(tool, sanitized);
registeredTools.push(tool.name);
}
}

Expand All @@ -184,20 +184,20 @@ export class DynamicToolManager {
}

/**
* @param sanitized - The toolset name that partially failed
* @param registeredTools - Tools that were registered before the failure
* Unregisters every tool of a toolset from the MCP server and the registry.
* Also rolls back a partially failed enable, so no orphaned tools stay callable.
*
* @param toolsetKey - The toolset whose tools to remove
* @returns The number of tools removed
*/
private handlePartialFailure(
sanitized: string,
registeredTools: string[]
): void {
if (registeredTools.length > 0) {
console.warn(
`Partial failure enabling toolset '${sanitized}'. ` +
`${registeredTools.length} tools were registered but toolset activation failed. ` +
`Tools remain registered due to MCP limitations: ${registeredTools.join(", ")}`
);
private removeToolsetTools(toolsetKey: string): number {
const handles = this.toolHandles.get(toolsetKey) ?? [];
for (const handle of handles) {
handle.remove();
}
this.toolHandles.delete(toolsetKey);
this.toolRegistry.removeForToolset(toolsetKey);
return handles.length;
}

/**
Expand Down Expand Up @@ -258,7 +258,7 @@ export class DynamicToolManager {
? (tool.inputSchema as ZodRawShapeCompat)
: z.object({}).passthrough();

this.server.registerTool(
const handle = this.server.registerTool(
tool.name,
{
title: tool.annotations?.title,
Expand All @@ -270,6 +270,10 @@ export class DynamicToolManager {
return await tool.handler(args);
}
);
this.toolHandles.set(toolsetKey, [
...(this.toolHandles.get(toolsetKey) ?? []),
handle,
]);
this.toolRegistry.addForToolset(toolsetKey, tool.name);
}

Expand Down Expand Up @@ -300,14 +304,14 @@ export class DynamicToolManager {
};
}

// State-only disable; no unregistration support in MCP
const removedCount = this.removeToolsetTools(sanitized);
this.activeToolsets.delete(sanitized);

await this.notifyToolsChanged();

return {
success: true,
message: `Toolset '${sanitized}' disabled successfully. Individual tools remain registered due to MCP limitations.`,
message: `Toolset '${sanitized}' disabled successfully. Removed ${removedCount} tools.`,
};
}

Expand Down
21 changes: 17 additions & 4 deletions src/core/ServerOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,23 @@ export class ServerOrchestrator {
initial: string[] | "ALL" | undefined
): Promise<void> {
try {
if (initial === "ALL") {
await this.manager.enableToolsets(this.resolver.getAvailableToolsets());
} else if (Array.isArray(initial) && initial.length > 0) {
await this.manager.enableToolsets(initial);
const names =
initial === "ALL"
? this.resolver.getAvailableToolsets()
: Array.isArray(initial)
? initial
: [];
if (names.length > 0) {
// enableToolsets reports failures in its result instead of throwing.
// Only a failure while registering tools carries a code; refusals made
// before any registration (policy, already enabled) leave nothing partial.
const { results } = await this.manager.enableToolsets(names);
const failures = results.filter((r) => !r.success && r.code);
if (failures.length > 0) {
throw new Error(
`Failed to enable startup toolsets: ${failures.map((r) => r.message).join("; ")}`
);
}
}
} catch (error) {
this.initError =
Expand Down
12 changes: 12 additions & 0 deletions src/core/ToolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export class ToolRegistry {
this.toolsetToNames.set(toolsetKey, set);
}

/**
* Forgets every tool name registered for a toolset, so the names can be registered again.
*
* @param toolsetKey - The toolset whose tools to forget
*/
public removeForToolset(toolsetKey: string): void {
for (const name of this.toolsetToNames.get(toolsetKey) ?? []) {
this.names.delete(name);
}
this.toolsetToNames.delete(toolsetKey);
}

public mapAndValidate(
toolsetKey: string,
tools: McpToolDefinition[]
Expand Down
Loading
Loading