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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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
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
20 changes: 15 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,30 @@ 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).

## [Unreleased]
## [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.
- `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"] }`.

### Breaking
### Changed

- `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. As a result `createMcpServer` rejects at startup for such a catalog in STATIC mode, where it used to start with the partial toolset. Fix the catalog (rename or remove the duplicate tool) before upgrading. `enableToolset` results (and the `enable_toolset` meta-tool output) gain an optional `code` on that failure. Toolsets refused before registration (exposure policy, duplicate names in `startup.toolsets`) and module loader errors do not count as failures.
- `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

- A STATIC client whose startup toolsets fail to load now gets a JSON-RPC error (`-32603`, HTTP 500) 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.
- `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]

Expand Down
1 change: 1 addition & 0 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
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
1 change: 1 addition & 0 deletions src/permissions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ HTTP Request with mcp-client-id header
→ Create STATIC mode server with allowed toolsets only
→ Return bundle (cached for reuse)
→ On initialize: await bundle.orchestrator.isReady() before server.connect() — the SDK refuses capability registration after connect
→ not ready → drop the bundle from the cache, respond 500 / JSON-RPC -32603 (same as FastifyTransport). Only a hand-built bundle whose orchestrator preloads startup toolsets itself can be not ready: `createPermissionBasedMcpServer` builds its orchestrators with `toolsets: []` and enables the permitted ones in the factory, so invariant 4 (partial success continues) still holds there
```

## Anti-patterns
Expand Down
18 changes: 17 additions & 1 deletion src/permissions/PermissionAwareFastifyTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,23 @@ export class PermissionAwareFastifyTransport {
try {
// Bundles may preload toolsets asynchronously; the SDK refuses to
// register tool capabilities once connected, so wait for them first.
await bundle.orchestrator.isReady();
const ready = await bundle.orchestrator.isReady();
if (!ready) {
// A failed preload is cached on the orchestrator for good, so drop
// the bundle and let the client's next initialize start a fresh one.
if (this.clientCache.get(context.clientId) === bundle) {
this.clientCache.delete(context.clientId);
}
reply.code(500);
return {
jsonrpc: "2.0",
error: {
code: -32603,
message: "Failed to load toolsets for this client.",
},
id: null,
};
}
await bundle.server.connect(transport);
} catch (error) {
reply.code(500);
Expand Down
132 changes: 131 additions & 1 deletion tests/e2e/permissionBased.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,137 @@ describe("Permission-based E2E", () => {
});
});

describe("bundles that preload asynchronously", () => {
describe("when a permitted toolset fails to register its tools", () => {
let server: Awaited<ReturnType<typeof createPermissionBasedMcpServer>>;
let port: number;

beforeAll(async () => {
port = await getAvailablePort();
server = await createPermissionBasedMcpServer({
createServer: () =>
new McpServer({ name: "test-partial-failure", version: "1.0.0" }),
catalog: {
good: { name: "Good", description: "Loads", modules: ["good"] },
dup: { name: "Dup", description: "Duplicate tool name", modules: ["dup"] },
},
moduleLoaders: {
good: async () => testCatalog.core.tools!,
// One tool twice: registration fails halfway through.
dup: async () => [...testCatalog.core.tools!, ...testCatalog.core.tools!],
},
permissions: { source: "headers" },
http: { port },
});
await server.start();
});

afterAll(async () => {
await server.close();
});

const connect = async (clientId: string, toolsets: string) => {
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{
requestInit: {
headers: {
"mcp-client-id": clientId,
"mcp-toolset-permissions": toolsets,
},
},
}
)
);
return client;
};

// Pins the documented permission behaviour: partial success continues,
// so the failed-load rejection of createMcpServer does not apply here.
it("still connects with the toolsets that registered", async () => {
const client = await connect("partial-client", "good,dup");
const toolNames = extractToolNames(await client.listTools());
expect(toolNames).toContain("good.ping");
expect(toolNames).not.toContain("dup.ping");
await client.close();
});

it("refuses a client whose permitted toolsets all failed", async () => {
await expect(connect("all-failed-client", "dup")).rejects.toThrow(/Access denied/);
});
});

describe("transport level: bundles whose startup toolsets fail to load", () => {
let transport: PermissionAwareFastifyTransport;
let port: number;
let flakyAttempts = 0;

beforeAll(async () => {
port = await getAvailablePort();
transport = PermissionAwareFastifyTransport.builder()
.defaultManager(
ServerOrchestrator.builder()
.server(new McpServer({ name: "base", version: "1.0.0" }))
.catalog(testCatalog)
.build()
.getManager()
)
.createPermissionAwareBundle(async (context) => {
const server = new McpServer({ name: "failing-bundle", version: "1.0.0" });
const orchestrator = ServerOrchestrator.builder()
.server(server)
.catalog({
dup: { name: "Dup", description: "Duplicate tool name", modules: ["dup"] },
})
.moduleLoaders({
// Returning one tool twice makes registration fail halfway through.
dup: async () => {
const tools = testCatalog.core.tools!;
const fail =
context.clientId === "failing-client" ||
(context.clientId === "flaky-client" && flakyAttempts++ === 0);
return fail ? [...tools, ...tools] : tools;
},
})
.startup({ mode: "STATIC", toolsets: ["dup"] })
.registerMetaTools(false)
.build();
return { server, orchestrator, allowedToolsets: ["dup"], failedToolsets: [] };
})
.port(port)
.build();
await transport.start();
});

afterAll(async () => {
await transport.stop();
});

const connect = async (clientId: string) => {
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
{ requestInit: { headers: { "mcp-client-id": clientId } } }
)
);
return client;
};

it("rejects the client instead of connecting with partial tools", async () => {
await expect(connect("failing-client")).rejects.toThrow(/Failed to load toolsets/);
});

it("lets the same client retry after a failed load", async () => {
await expect(connect("flaky-client")).rejects.toThrow(/Failed to load toolsets/);
const client = await connect("flaky-client");
expect(extractToolNames(await client.listTools())).toContain("dup.ping");
await client.close();
});
});

describe("transport level: bundles that preload asynchronously", () => {
const slowCatalog = {
slow: {
name: "Slow",
Expand Down
Loading