From f41f389d7f34fde26e4a7ee55ebfa4b77585699b Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 11:17:00 +0200 Subject: [PATCH 01/11] chore: bug report --- ANONYMOUS_CLIENT_BUG.md | 147 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 ANONYMOUS_CLIENT_BUG.md diff --git a/ANONYMOUS_CLIENT_BUG.md b/ANONYMOUS_CLIENT_BUG.md new file mode 100644 index 0000000..250d5da --- /dev/null +++ b/ANONYMOUS_CLIENT_BUG.md @@ -0,0 +1,147 @@ +# Anonymous Client Bug - Second Request Failure + +## Problem Statement + +When a client connects to the Toolception MCP server **without** providing an `mcp-client-id` header, the second request always fails with: + +```json +{ + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": "Session not found or expired" + } +} +``` + +This occurs even when the client properly sends the `mcp-session-id` header received from the initialize response. + +## Root Cause Analysis + +The issue stems from two compounding problems in `src/http/FastifyTransport.ts`: + +### Problem 1: New Anonymous ID Per Request + +**Location:** `FastifyTransport.ts:131-137` + +```typescript +const clientIdHeader = (req.headers["mcp-client-id"] as string | undefined)?.trim(); +const clientId = + clientIdHeader && clientIdHeader.length > 0 + ? clientIdHeader + : `anon-${randomUUID()}`; // ← GENERATES NEW UUID EVERY REQUEST +``` + +Each request without an `mcp-client-id` header gets a **different** anonymous ID: +- Request 1: `anon-abc123` +- Request 2: `anon-def456` ← Different! + +### Problem 2: Anonymous Clients Not Cached + +**Location:** `FastifyTransport.ts:139-156` + +```typescript +// When anon id, avoid caching (one-off) +const useCache = !clientId.startsWith("anon-"); // ← useCache = false + +let bundle = useCache ? this.clientCache.get(cacheKey) : null; +if (!bundle) { + const created = this.createBundle(mergedContext); + bundle = { + server: created.server, + orchestrator: created.orchestrator, + sessions: new Map(), // ← Fresh, empty sessions Map + }; + if (useCache) this.clientCache.set(cacheKey, bundle); // ← SKIPPED for anon +} +``` + +Anonymous clients: +1. Skip cache lookup (`useCache = false`) +2. Create fresh bundle with empty `sessions` Map +3. Skip cache storage + +## Request Flow Breakdown + +### Request 1 (Initialize) + +``` +1. Client sends POST /mcp (no mcp-client-id header) +2. Server generates: clientId = "anon-abc123" +3. useCache = false +4. bundle = null (cache skipped) +5. Create NEW bundle with empty sessions Map +6. Initialize creates transport +7. Session stored: bundle.sessions.set("session-xyz", transport) + ↑ Stored in uncached bundle +8. Response sent to client with mcp-session-id: "session-xyz" +9. Bundle NOT cached (useCache is false) + ↑ Bundle is discarded after response +``` + +### Request 2 (Tool Call) + +``` +1. Client sends POST /mcp + Headers: { "mcp-session-id": "session-xyz" } +2. Server generates: clientId = "anon-def456" ← DIFFERENT ID +3. useCache = false +4. bundle = null (cache skipped) +5. Create NEW bundle with EMPTY sessions Map + ↑ Previous session lost +6. Try to find session: bundle.sessions.get("session-xyz") + → Returns undefined (sessions Map is empty) +7. Falls through to error (line 186-192) +8. Returns 400: "Session not found or expired" +``` + +## Why This Happens + +The MCP protocol requires **session continuity** across multiple requests: +1. Initialize request creates a session +2. Subsequent requests reuse that session via `mcp-session-id` + +For this to work, the server must: +- **Recognize the same client** across requests +- **Persist the session state** (the bundle with sessions Map) + +Anonymous clients fail both requirements: +- ❌ Different `clientId` each request (new UUID) +- ❌ Bundles not cached (destroyed after each request) + +## Impact + +This bug makes the MCP protocol **completely broken** for anonymous clients: +- ✅ Initialize works (creates session) +- ❌ All subsequent requests fail (session not found) +- ❌ SSE streaming broken +- ❌ Tool calls impossible +- ❌ Multi-request workflows impossible + +## Solution: Require mcp-client-id Header + +**Recommended Fix:** Make `mcp-client-id` header **required** for all MCP protocol requests. + +**Rationale:** +- MCP protocol inherently requires session continuity +- Session continuity requires client identity persistence +- Anonymous mode is fundamentally incompatible with the protocol +- Requiring the header makes expectations clear + +### Implementation Details + +1. **Add Zod validation** for the header in POST /mcp, GET /mcp, DELETE /mcp +2. **Return 400 error** if header is missing or empty +3. **Update documentation** (README.md, AGENTS.md files) +4. **Update tests** to always provide the header +5. **Remove anonymous ID generation** logic + +## Files to Modify + +- `src/http/FastifyTransport.ts` - Add header validation +- `src/http/PermissionAwareFastifyTransport.ts` - Same validation +- `src/http/AGENTS.md` - Update invariants and docs +- `README.md` - Update client integration examples +- `tests/fastifyTransport.test.ts` - Update test expectations +- `tests/smoke-e2e/README.md` - Update examples +- Any other test files using anonymous connections From 3c48bc234145c68bcd1e0a6123ec70ca7dc91b1b Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 11:46:23 +0200 Subject: [PATCH 02/11] fix: anon client --- ANONYMOUS_CLIENT_BUG.md | 147 ------------------ README.md | 2 +- src/http/AGENTS.md | 8 +- src/http/FastifyTransport.ts | 32 ++-- src/permissions/AGENTS.md | 8 +- .../PermissionAwareFastifyTransport.ts | 29 +++- tests/fastifyTransport.test.ts | 71 +++++++++ tests/permissionAwareFastifyTransport.test.ts | 68 ++++---- 8 files changed, 163 insertions(+), 202 deletions(-) delete mode 100644 ANONYMOUS_CLIENT_BUG.md diff --git a/ANONYMOUS_CLIENT_BUG.md b/ANONYMOUS_CLIENT_BUG.md deleted file mode 100644 index 250d5da..0000000 --- a/ANONYMOUS_CLIENT_BUG.md +++ /dev/null @@ -1,147 +0,0 @@ -# Anonymous Client Bug - Second Request Failure - -## Problem Statement - -When a client connects to the Toolception MCP server **without** providing an `mcp-client-id` header, the second request always fails with: - -```json -{ - "jsonrpc": "2.0", - "error": { - "code": -32000, - "message": "Session not found or expired" - } -} -``` - -This occurs even when the client properly sends the `mcp-session-id` header received from the initialize response. - -## Root Cause Analysis - -The issue stems from two compounding problems in `src/http/FastifyTransport.ts`: - -### Problem 1: New Anonymous ID Per Request - -**Location:** `FastifyTransport.ts:131-137` - -```typescript -const clientIdHeader = (req.headers["mcp-client-id"] as string | undefined)?.trim(); -const clientId = - clientIdHeader && clientIdHeader.length > 0 - ? clientIdHeader - : `anon-${randomUUID()}`; // ← GENERATES NEW UUID EVERY REQUEST -``` - -Each request without an `mcp-client-id` header gets a **different** anonymous ID: -- Request 1: `anon-abc123` -- Request 2: `anon-def456` ← Different! - -### Problem 2: Anonymous Clients Not Cached - -**Location:** `FastifyTransport.ts:139-156` - -```typescript -// When anon id, avoid caching (one-off) -const useCache = !clientId.startsWith("anon-"); // ← useCache = false - -let bundle = useCache ? this.clientCache.get(cacheKey) : null; -if (!bundle) { - const created = this.createBundle(mergedContext); - bundle = { - server: created.server, - orchestrator: created.orchestrator, - sessions: new Map(), // ← Fresh, empty sessions Map - }; - if (useCache) this.clientCache.set(cacheKey, bundle); // ← SKIPPED for anon -} -``` - -Anonymous clients: -1. Skip cache lookup (`useCache = false`) -2. Create fresh bundle with empty `sessions` Map -3. Skip cache storage - -## Request Flow Breakdown - -### Request 1 (Initialize) - -``` -1. Client sends POST /mcp (no mcp-client-id header) -2. Server generates: clientId = "anon-abc123" -3. useCache = false -4. bundle = null (cache skipped) -5. Create NEW bundle with empty sessions Map -6. Initialize creates transport -7. Session stored: bundle.sessions.set("session-xyz", transport) - ↑ Stored in uncached bundle -8. Response sent to client with mcp-session-id: "session-xyz" -9. Bundle NOT cached (useCache is false) - ↑ Bundle is discarded after response -``` - -### Request 2 (Tool Call) - -``` -1. Client sends POST /mcp - Headers: { "mcp-session-id": "session-xyz" } -2. Server generates: clientId = "anon-def456" ← DIFFERENT ID -3. useCache = false -4. bundle = null (cache skipped) -5. Create NEW bundle with EMPTY sessions Map - ↑ Previous session lost -6. Try to find session: bundle.sessions.get("session-xyz") - → Returns undefined (sessions Map is empty) -7. Falls through to error (line 186-192) -8. Returns 400: "Session not found or expired" -``` - -## Why This Happens - -The MCP protocol requires **session continuity** across multiple requests: -1. Initialize request creates a session -2. Subsequent requests reuse that session via `mcp-session-id` - -For this to work, the server must: -- **Recognize the same client** across requests -- **Persist the session state** (the bundle with sessions Map) - -Anonymous clients fail both requirements: -- ❌ Different `clientId` each request (new UUID) -- ❌ Bundles not cached (destroyed after each request) - -## Impact - -This bug makes the MCP protocol **completely broken** for anonymous clients: -- ✅ Initialize works (creates session) -- ❌ All subsequent requests fail (session not found) -- ❌ SSE streaming broken -- ❌ Tool calls impossible -- ❌ Multi-request workflows impossible - -## Solution: Require mcp-client-id Header - -**Recommended Fix:** Make `mcp-client-id` header **required** for all MCP protocol requests. - -**Rationale:** -- MCP protocol inherently requires session continuity -- Session continuity requires client identity persistence -- Anonymous mode is fundamentally incompatible with the protocol -- Requiring the header makes expectations clear - -### Implementation Details - -1. **Add Zod validation** for the header in POST /mcp, GET /mcp, DELETE /mcp -2. **Return 400 error** if header is missing or empty -3. **Update documentation** (README.md, AGENTS.md files) -4. **Update tests** to always provide the header -5. **Remove anonymous ID generation** logic - -## Files to Modify - -- `src/http/FastifyTransport.ts` - Add header validation -- `src/http/PermissionAwareFastifyTransport.ts` - Same validation -- `src/http/AGENTS.md` - Update invariants and docs -- `README.md` - Update client integration examples -- `tests/fastifyTransport.test.ts` - Update test expectations -- `tests/smoke-e2e/README.md` - Update examples -- Any other test files using anonymous connections diff --git a/README.md b/README.md index 49ee8b2..f795f05 100644 --- a/README.md +++ b/README.md @@ -1177,7 +1177,7 @@ await client.close(); - **What**: Clients identify themselves via the `mcp-client-id` HTTP header on every request. - **Who generates it**: The client. Use a stable identifier (e.g., UUID persisted locally). -- **If omitted**: The server assigns a one-off `anon-` and skips caching; this is unsuitable for multi-request flows and SSE. +- **If omitted**: MCP protocol endpoints (`POST /mcp`, `GET /mcp`, `DELETE /mcp`) return a 400 error. Custom endpoints still accept anonymous clients with auto-generated IDs. Examples (official MCP client) diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index ae52f92..8e5a10c 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -29,7 +29,7 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, ## Invariants -1. **Anonymous clients get `anon-` prefix** - Generated as `anon-${UUID}`, not cached +1. **`mcp-client-id` header required** - All MCP protocol endpoints (POST, GET, DELETE) reject requests without this header (400). Custom endpoints still generate `anon-${UUID}` fallback. 2. **Session created on POST /mcp initialize** - Tracked in `bundle.sessions` Map 3. **Cache key format** - `${clientId}:${contextHash}` when session context differs 4. **Reserved paths cannot be overridden** - `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` @@ -43,8 +43,8 @@ headers: Record // Query params filtered to string values query: Record -// Client ID from header or auto-generated -clientId: headers['mcp-client-id'] ?? `anon-${uuid()}` +// Client ID from header (required for /mcp endpoints, auto-generated for custom endpoints) +clientId: headers['mcp-client-id'] ``` ## Session Lifecycle @@ -87,7 +87,7 @@ definePermissionAwareEndpoint({...}) ## Anti-patterns - Registering endpoints on reserved paths (throws) -- Assuming client IDs persist (anonymous regenerated each request) +- Sending MCP protocol requests without `mcp-client-id` header (returns 400) - Blocking on SSE handlers (should be async) ## Dependencies diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index d7ec006..51cc117 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -5,6 +5,7 @@ import Fastify, { } from "fastify"; import cors from "@fastify/cors"; import { randomUUID } from "node:crypto"; +import { z } from "zod"; import type { DynamicToolManager } from "../core/DynamicToolManager.js"; import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { ClientResourceCache } from "../session/ClientResourceCache.js"; @@ -16,6 +17,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CustomEndpointDefinition } from "./customEndpoints.js"; import { registerCustomEndpoints } from "./endpointRegistration.js"; +const mcpClientIdSchema = z + .string({ message: "Missing required mcp-client-id header" }) + .trim() + .min(1, "mcp-client-id header must not be empty"); + export interface FastifyTransportOptions { host?: string; port?: number; @@ -128,16 +134,18 @@ export class FastifyTransport { app.post( `${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { - const clientIdHeader = ( - req.headers["mcp-client-id"] as string | undefined - )?.trim(); - const clientId = - clientIdHeader && clientIdHeader.length > 0 - ? clientIdHeader - : `anon-${randomUUID()}`; - - // When anon id, avoid caching (one-off) - const useCache = !clientId.startsWith("anon-"); + const parseResult = mcpClientIdSchema.safeParse( + req.headers["mcp-client-id"] + ); + if (!parseResult.success) { + reply.code(400); + return { + jsonrpc: "2.0", + error: { code: -32600, message: parseResult.error.issues[0].message }, + id: null, + }; + } + const clientId = parseResult.data; // Build session request context and resolve merged context const { cacheKey, mergedContext } = this.resolveSessionContext( @@ -145,7 +153,7 @@ export class FastifyTransport { clientId ); - let bundle = useCache ? this.clientCache.get(cacheKey) : null; + let bundle = this.clientCache.get(cacheKey); if (!bundle) { const created = this.createBundle(mergedContext); bundle = { @@ -153,7 +161,7 @@ export class FastifyTransport { orchestrator: created.orchestrator, sessions: new Map(), }; - if (useCache) this.clientCache.set(cacheKey, bundle); + this.clientCache.set(cacheKey, bundle); } const sessionId = req.headers["mcp-session-id"] as string | undefined; diff --git a/src/permissions/AGENTS.md b/src/permissions/AGENTS.md index 9aeb9c8..d263e74 100644 --- a/src/permissions/AGENTS.md +++ b/src/permissions/AGENTS.md @@ -24,7 +24,7 @@ Provides per-client access control for toolsets. Supports header-based and confi **PermissionAwareFastifyTransport** (`PermissionAwareFastifyTransport.ts`) - HTTP transport with permission enforcement - Per-client bundles via ClientResourceCache -- Anonymous clients (`anon-*`) not cached +- MCP endpoints require `mcp-client-id` header (400 if missing) ## Invariants @@ -50,14 +50,14 @@ Provides per-client access control for toolsets. Supports header-based and confi - Manual invalidation required **PermissionAwareFastifyTransport cache:** -- Keyed by clientId (non-anonymous only) +- Keyed by clientId (all MCP clients, since header is required) - LRU eviction with onEvict cleanup - Closes all sessions in bundle on eviction ## Anti-patterns - Expecting cache to auto-invalidate (it won't) -- Caching anonymous client bundles (they're excluded) +- Sending MCP protocol requests without `mcp-client-id` header (returns 400) - Trusting client-provided permissions without config-based fallback ## Permission Flow @@ -73,7 +73,7 @@ PermissionResolver.resolve(clientId, headers) ↓ Create STATIC mode server with allowed toolsets only ↓ -Return bundle (cached if non-anonymous) +Return bundle (cached for reuse) ``` ## Dependencies diff --git a/src/permissions/PermissionAwareFastifyTransport.ts b/src/permissions/PermissionAwareFastifyTransport.ts index f0f6f5a..d271120 100644 --- a/src/permissions/PermissionAwareFastifyTransport.ts +++ b/src/permissions/PermissionAwareFastifyTransport.ts @@ -5,6 +5,7 @@ import Fastify, { } from "fastify"; import cors from "@fastify/cors"; import { randomUUID } from "node:crypto"; +import { z } from "zod"; import type { DynamicToolManager } from "../core/DynamicToolManager.js"; import { ClientResourceCache } from "../session/ClientResourceCache.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -18,6 +19,11 @@ import type { import type { CustomEndpointDefinition } from "../http/customEndpoints.js"; import { registerCustomEndpoints } from "../http/endpointRegistration.js"; +const mcpClientIdSchema = z + .string({ message: "Missing required mcp-client-id header" }) + .trim() + .min(1, "mcp-client-id header must not be empty"); + export interface PermissionAwareFastifyTransportOptions { host?: string; port?: number; @@ -264,14 +270,24 @@ export class PermissionAwareFastifyTransport { app.post( `${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { + // Validate mcp-client-id header + const parseResult = mcpClientIdSchema.safeParse( + req.headers["mcp-client-id"] + ); + if (!parseResult.success) { + reply.code(400); + return { + jsonrpc: "2.0", + error: { code: -32600, message: parseResult.error.issues[0].message }, + id: null, + }; + } + // Extract client context from request const context = this.#extractClientContext(req); - // Determine if we should cache this client's bundle - const useCache = !context.clientId.startsWith("anon-"); - // Get or create permission-aware bundle for this client - let bundle = useCache ? this.clientCache.get(context.clientId) : null; + let bundle = this.clientCache.get(context.clientId); if (!bundle) { try { const created = await this.createPermissionAwareBundle(context); @@ -294,7 +310,7 @@ export class PermissionAwareFastifyTransport { sessions: providedSessions instanceof Map ? providedSessions : new Map(), }; - if (useCache) this.clientCache.set(context.clientId, bundle); + this.clientCache.set(context.clientId, bundle); } catch (error) { // Handle permission resolution or bundle creation failures console.error( @@ -446,7 +462,8 @@ export class PermissionAwareFastifyTransport { /** * Extracts client context from the request. - * Generates anonymous client ID if not provided in headers. + * Generates anonymous client ID if header is missing. MCP protocol endpoints + * validate the header separately before calling this. * @param req - Fastify request object * @returns Client request context with ID and headers * @private diff --git a/tests/fastifyTransport.test.ts b/tests/fastifyTransport.test.ts index f2305ae..7d1ae49 100644 --- a/tests/fastifyTransport.test.ts +++ b/tests/fastifyTransport.test.ts @@ -42,6 +42,77 @@ describe("FastifyTransport", () => { await transport.stop(); }); + it("POST /mcp without mcp-client-id returns 400", async () => { + const server: any = { + async connect(_t: any) {}, + }; + const resolver = new ModuleResolver({ + catalog: { core: { name: "Core", description: "", tools: [] } } as any, + }); + const manager = new DynamicToolManager({ server, resolver }); + + const app = Fastify({ logger: false }); + + const transport = new FastifyTransport( + manager, + () => ({ server, orchestrator: {} as any }), + { port: 0, logger: false, app } + ); + await transport.start(); + + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: {}, + payload: { + jsonrpc: "2.0", + method: "initialize", + params: {}, + id: 1, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe(-32600); + expect(res.json().error.message).toContain("mcp-client-id"); + + await transport.stop(); + }); + + it("POST /mcp with whitespace-only mcp-client-id returns 400", async () => { + const server: any = { + async connect(_t: any) {}, + }; + const resolver = new ModuleResolver({ + catalog: { core: { name: "Core", description: "", tools: [] } } as any, + }); + const manager = new DynamicToolManager({ server, resolver }); + + const app = Fastify({ logger: false }); + + const transport = new FastifyTransport( + manager, + () => ({ server, orchestrator: {} as any }), + { port: 0, logger: false, app } + ); + await transport.start(); + + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { "mcp-client-id": " " }, + payload: { + jsonrpc: "2.0", + method: "initialize", + params: {}, + id: 1, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe(-32600); + + await transport.stop(); + }); + it("DELETE /mcp returns proper errors for invalid requests", async () => { const server: any = { async connect(_t: any) {}, diff --git a/tests/permissionAwareFastifyTransport.test.ts b/tests/permissionAwareFastifyTransport.test.ts index 87e8e9a..6ee1cef 100644 --- a/tests/permissionAwareFastifyTransport.test.ts +++ b/tests/permissionAwareFastifyTransport.test.ts @@ -78,31 +78,32 @@ describe("PermissionAwareFastifyTransport", () => { await transport.stop(); }); - it("generates anonymous client ID when header is missing", async () => { - const capturedContexts: ClientRequestContext[] = []; - const spyCreateBundle = vi.fn( - async (context: ClientRequestContext): Promise => { - capturedContexts.push(context); - return { - server: mockServer, - orchestrator: mockOrchestrator, - allowedToolsets: [], - }; - } - ); - + it("rejects POST /mcp without mcp-client-id header", async () => { + const app = Fastify({ logger: false }); const transport = new PermissionAwareFastifyTransport( mockManager, - spyCreateBundle, - { app: Fastify({ logger: false }) } + mockCreateBundle, + { app } ); await transport.start(); - await transport.stop(); - // We can't easily test the POST handler without making actual HTTP requests, - // but we've verified the transport initializes correctly - expect(spyCreateBundle).toBeDefined(); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: {}, + payload: { + jsonrpc: "2.0", + method: "initialize", + params: {}, + id: 1, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe(-32600); + expect(res.json().error.message).toContain("mcp-client-id"); + + await transport.stop(); }); it("passes headers to bundle creator", async () => { @@ -149,7 +150,7 @@ describe("PermissionAwareFastifyTransport", () => { expect(createBundleSpy).toBeDefined(); }); - it("caches bundle for non-anonymous clients", async () => { + it("caches bundle for clients", async () => { const createBundleSpy = vi.fn(mockCreateBundle); const transport = new PermissionAwareFastifyTransport( @@ -165,20 +166,31 @@ describe("PermissionAwareFastifyTransport", () => { expect(createBundleSpy).toBeDefined(); }); - it("does not cache bundle for anonymous clients", async () => { - const createBundleSpy = vi.fn(mockCreateBundle); - + it("rejects POST /mcp with whitespace-only mcp-client-id", async () => { + const app = Fastify({ logger: false }); const transport = new PermissionAwareFastifyTransport( mockManager, - createBundleSpy, - { app: Fastify({ logger: false }) } + mockCreateBundle, + { app } ); await transport.start(); - await transport.stop(); - // Anonymous clients (anon-*) are not cached - expect(createBundleSpy).toBeDefined(); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { "mcp-client-id": " " }, + payload: { + jsonrpc: "2.0", + method: "initialize", + params: {}, + id: 1, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe(-32600); + + await transport.stop(); }); }); From 7c355ac540f010a73bd42e2cc2b684fece409a56 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 11:51:39 +0200 Subject: [PATCH 03/11] chore: intent layer entry --- AGENTS.md | 145 ++++++++++++++++++++++++-------------- CLAUDE.md | 112 +++-------------------------- src/core/AGENTS.md | 2 +- src/http/AGENTS.md | 2 +- src/mode/AGENTS.md | 2 +- src/permissions/AGENTS.md | 2 +- src/server/AGENTS.md | 2 +- src/session/AGENTS.md | 2 +- src/types/AGENTS.md | 2 +- 9 files changed, 110 insertions(+), 161 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 659c3fa..e9f34ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,71 +1,114 @@ -# Using Toolception with Agents/LLMs +# Toolception — Intent Layer Root -## Capabilities by mode +## What This Is -- Dynamic mode: - - Meta-tools available: `enable_toolset`, `disable_toolset`, `list_toolsets`, `describe_toolset`, and `list_tools`. - - Tools may change at runtime; server advertises `tools.listChanged` capability. -- Static mode: - - Meta-tools available: `list_tools` only. Do not attempt to enable/disable toolsets. +Dynamic MCP server toolkit for runtime toolset management. Groups tools into toolsets, exposes only what's needed — reducing prompt/tool surface area for LLMs. -## Recommended agent flow +## Architecture -1. Discover current tools - - Always call `list_tools()` first. Response includes `tools` and `toolsetToTools`. -2. (Dynamic only) Discover available toolsets - - If `list_toolsets` is present, call it to see all toolsets, their definitions, and which are active. -3. (Dynamic only) Enable toolsets on demand - - Call `enable_toolset({ name })` for a specific toolset. Handle failures: - - Not allowed/denied by policy - - Already active - - Max active toolsets exceeded -4. Invoke task-specific tools using namespaced names (e.g., `search.find`). -5. (Optional) Disable toolsets no longer needed via `disable_toolset({ name })` (state-only). +``` +src/ +├── types/ # Contracts (leaf — no deps) +├── core/ # ServerOrchestrator, DynamicToolManager, ToolRegistry +├── mode/ # ModuleResolver, toolset validation +├── meta/ # Meta-tool registration (enable/disable/list) +├── server/ # createMcpServer, createPermissionBasedMcpServer +├── http/ # FastifyTransport, custom endpoints +├── session/ # SessionContextResolver, ClientResourceCache +├── permissions/ # PermissionResolver, PermissionAwareFastifyTransport +└── errors/ # ToolingError (18 LOC) +``` -## Tool naming +### Data Flow -- Tools are namespaced by toolset (e.g., `search.find`) to avoid collisions. -- Namespace policy may be customized; default is ON. +``` +Client → HTTP (Fastify) → Per-client MCP Server → ServerOrchestrator + ↓ + DynamicToolManager + ↓ ↓ + ModuleResolver ToolRegistry + ↓ + ModuleLoaders(context) +``` -## Error handling cues +### Two Server Modes -Meta-tool return formats: -- `enable_toolset` and `disable_toolset` return `{ success: boolean, message: string }` -- `list_tools` returns `{ tools: string[], toolsetToTools: Record }` -- `list_toolsets` returns `{ toolsets: Array<{ key, active, definition, tools }> }` -- `describe_toolset` returns `{ key, active, definition, tools }` or `{ error: string }` if unknown +| | DYNAMIC | STATIC | +|---|---|---| +| Toolsets | Enabled at runtime via meta-tools | Pre-loaded at startup | +| Server instances | Per-client | Shared singleton | +| Meta-tools | All 5 registered | `list_tools` only | +| Use case | Task-specific, lazy loading | Fixed pipelines | -For `enable_toolset`/`disable_toolset`, read `message` to adapt decisions (e.g., policy denial, already active, limits exceeded). +### Permission-Based Variant -## HTTP endpoints +`createPermissionBasedMcpServer` — per-client STATIC servers with access control. Permissions resolved from headers or server config. No meta-tools. -### Built-in MCP endpoints +## Critical Invariants -- `GET /healthz` - Health check -- `GET /tools` - List available toolsets and tools -- `POST /mcp` - MCP JSON-RPC requests (accepts `?config=` query parameter for session context) -- `GET /mcp` - Server-sent events stream (accepts `?config=` query parameter for session context) -- `DELETE /mcp` - Close session -- `GET /.well-known/mcp-config` - Configuration schema +1. **All tools → ToolRegistry** — Collision detection happens here only +2. **Disable ≠ Unregister** — MCP SDK limitation; disabled tools remain callable +3. **STATIC + sessionContext** — Session context ignored in STATIC mode +4. **Fail-secure** — Invalid inputs return empty objects, not errors +5. **Silent module failures** — Toolsets activate with partial tools if loaders fail +6. **`mcp-client-id` required for /mcp** — POST, GET, DELETE all reject without header (400) -### Custom HTTP endpoints +## Module Index -Servers may expose custom REST-like endpoints alongside MCP protocol endpoints. These are defined by the server implementer and provide direct HTTP access to functionality. +Read the relevant Intent Node before working in that area: -- Custom endpoints use standard HTTP methods (GET, POST, PUT, DELETE, PATCH) -- Request/response validation via Zod schemas -- Access to client ID via `mcp-client-id` header -- Permission-aware endpoints receive client's allowed toolsets -- Standard error format with `VALIDATION_ERROR`, `INTERNAL_ERROR`, or `RESPONSE_VALIDATION_ERROR` codes +| Module | Intent Node | Covers | +|--------|-------------|--------| +| Types | `src/types/AGENTS.md` | All interfaces, contracts, error codes | +| Core | `src/core/AGENTS.md` | ServerOrchestrator, DynamicToolManager, ToolRegistry | +| Mode | `src/mode/AGENTS.md` | ModuleResolver, toolset validation | +| Server | `src/server/AGENTS.md` | createMcpServer, createPermissionBasedMcpServer | +| HTTP | `src/http/AGENTS.md` | FastifyTransport, endpoints, SSE, custom endpoints | +| Session | `src/session/AGENTS.md` | SessionContextResolver, ClientResourceCache | +| Permissions | `src/permissions/AGENTS.md` | PermissionResolver, PermissionAwareFastifyTransport | -Check `GET /tools` or server documentation to discover available custom endpoints. +## Maintaining Intent Nodes -### Headers +**AI agents working in this codebase must keep Intent Nodes up to date.** When you: + +- **Add a new invariant** → Document it in the relevant Intent Node +- **Change component behavior** → Update the affected Intent Node +- **Add new components** → Add to Key Components section +- **Discover an anti-pattern** → Add to Anti-patterns section +- **Create a new module** → Create a corresponding `AGENTS.md` + +Intent Nodes should remain concise (~100 lines max). Focus on what an agent needs to work safely in that area. + +## Consumer Reference + +For agents/LLMs **using** Toolception tools at runtime (not developing the codebase): + +### Meta-tools (DYNAMIC mode) -- `mcp-client-id`: Client identifier (reuse for per-client sessions) -- `mcp-session-id`: Session identifier (managed by MCP transport after initialize) -- `mcp-toolset-permissions`: Comma-separated toolset list (permission-based servers with header-based permissions) +- `list_tools()` → `{tools, toolsetToTools}` — Always call first +- `list_toolsets()` → Discover available toolsets +- `enable_toolset({name})` / `disable_toolset({name})` — Runtime control +- `describe_toolset({name})` → Toolset details + +Tools are namespaced by toolset (e.g., `search.find`). Error responses include `{success, message}`. + +### HTTP Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/healthz` | Health check | +| GET | `/tools` | Tool/toolset status | +| POST | `/mcp` | JSON-RPC (requires `mcp-client-id`) | +| GET | `/mcp` | SSE stream (requires `mcp-client-id`) | +| DELETE | `/mcp` | Close session (requires `mcp-client-id`) | +| GET | `/.well-known/mcp-config` | Config schema | + +### Headers -### Query parameters +- `mcp-client-id`: **Required** for `/mcp` endpoints. Stable client identifier. +- `mcp-session-id`: Session ID from server (managed by transport after initialize) +- `mcp-toolset-permissions`: Comma-separated toolsets (permission-based, header source) +- `config` query param: Base64-encoded JSON for per-session context (if enabled) -- `config`: Base64-encoded JSON containing session-specific context (if server has `sessionContext` enabled). Used for multi-tenant scenarios where each client needs different context values (API tokens, user IDs) passed to module loaders. +--- +*This is the Intent Layer root. See leaf nodes for module-specific detail.* diff --git a/CLAUDE.md b/CLAUDE.md index 366054a..120b76c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Toolception is a dynamic MCP (Model Context Protocol) server toolkit for runtime toolset management. It allows grouping tools into toolsets and exposing only what's needed, when it's needed—reducing prompt/tool surface area for LLMs. +## Architecture & Intent Layer + +**Start with `AGENTS.md`** — the intent layer root. It provides the architectural overview, critical invariants, module index, and links to module-specific Intent Nodes. + +Read the relevant Intent Node before working in any area of the codebase. + ## Common Commands ```bash @@ -35,106 +41,6 @@ npm run dev:server-demo npm run dev:client-demo ``` -## Intent Layer Navigation - -Read the relevant Intent Node before working in that area: - -- `src/types/AGENTS.md` - Type definitions and contracts -- `src/core/AGENTS.md` - ServerOrchestrator, DynamicToolManager, ToolRegistry -- `src/mode/AGENTS.md` - ModuleResolver, validation utilities -- `src/server/AGENTS.md` - createMcpServer, createPermissionBasedMcpServer -- `src/http/AGENTS.md` - FastifyTransport, endpoints, SSE -- `src/session/AGENTS.md` - SessionContextResolver, ClientResourceCache -- `src/permissions/AGENTS.md` - PermissionResolver, access control - -### Maintaining the Intent Layer - -**AI agents working in this codebase must keep Intent Nodes up to date.** When you: - -- **Add a new invariant** → Document it in the relevant Intent Node -- **Change component behavior** → Update the affected Intent Node's description -- **Add new components** → Add them to the Key Components section -- **Discover an anti-pattern** → Add it to the Anti-patterns section -- **Create a new module** → Create a corresponding `AGENTS.md` Intent Node - -Intent Nodes should remain concise (~100 lines max). Focus on what an agent needs to know to work safely in that area. - -## Critical Invariants - -1. **All tools → ToolRegistry** - Collision detection happens here only -2. **Disable ≠ Unregister** - MCP SDK limitation; disabled tools remain callable -3. **STATIC + sessionContext** - Session context ignored in STATIC mode -4. **Fail-secure** - Invalid inputs return empty objects, not errors -5. **Silent module failures** - Toolsets activate with partial tools if loaders fail - -## Architecture - -### Core Components - -**ServerOrchestrator** (`src/core/ServerOrchestrator.ts`) -- Entry point that wires together all components -- Resolves startup mode (DYNAMIC vs STATIC) from configuration -- Creates ModuleResolver, DynamicToolManager, and ToolRegistry -- Registers meta-tools based on mode - -**DynamicToolManager** (`src/core/DynamicToolManager.ts`) -- Manages toolset lifecycle (enable/disable) -- Enforces exposure policies (allowlist, denylist, maxActiveToolsets) -- Registers tools with the MCP server -- Tracks active toolsets and sends change notifications - -**ToolRegistry** (`src/core/ToolRegistry.ts`) -- Central registry preventing tool name collisions -- Handles namespacing (e.g., `toolset.toolname`) -- Maps toolsets to their registered tools - -**ModuleResolver** (`src/mode/ModuleResolver.ts`) -- Resolves tools from toolset definitions -- Loads module-produced tools via moduleLoaders -- Validates toolset names against catalog - -### Server Creation APIs - -Two main factory functions in `src/server/`: -- `createMcpServer` - Standard server with DYNAMIC or STATIC modes -- `createPermissionBasedMcpServer` - Per-client toolset access control - -### HTTP Transport - -**FastifyTransport** (`src/http/FastifyTransport.ts`) -- Fastify-based HTTP transport for MCP protocol -- Handles SSE streams, JSON-RPC requests -- Per-client server instances in DYNAMIC mode - -**PermissionAwareFastifyTransport** (`src/http/PermissionAwareFastifyTransport.ts`) -- Extends FastifyTransport with permission checking -- Supports header-based or config-based permissions - -### Session Context - -**SessionContextResolver** (`src/session/SessionContextResolver.ts`) -- Parses query parameter (base64/json encoding) -- Filters allowed keys (whitelist enforcement) -- Merges session context with base context (shallow or deep) -- Generates cache key suffix for session differentiation - -### Key Types (`src/types/index.ts`) - -- `McpToolDefinition` - Tool with name, description, inputSchema, handler, optional annotations -- `ToolSetDefinition` - Groups tools with name, description, optional modules -- `ToolSetCatalog` - Record of toolset key to definition -- `ExposurePolicy` - Controls maxActiveToolsets, allowlist, denylist, namespacing -- `PermissionConfig` - Header or config-based permission source -- `SessionContextConfig` - Per-session context configuration (query params, encoding, merge strategy) -- `SessionRequestContext` - Request context (clientId, headers, query) for context resolvers - -### Meta-tools (DYNAMIC mode) - -Registered in `src/meta/registerMetaTools.ts`: -- `enable_toolset` / `disable_toolset` - Activate/deactivate toolsets -- `list_toolsets` / `describe_toolset` - Discovery -- `list_tools` - List currently registered tools - ## Testing Patterns Tests use Vitest with in-memory mocks. Key patterns: @@ -145,10 +51,10 @@ Tests use Vitest with in-memory mocks. Key patterns: ### Key Test Files -- `tests/sessionContextResolver.test.ts` - Unit tests for SessionContextResolver (parsing, filtering, merging) +- `tests/sessionContextResolver.test.ts` - Unit tests for SessionContextResolver - `tests/validateSessionContextConfig.test.ts` - Validation tests for SessionContextConfig -- `tests/sessionContext.integration.test.ts` - Integration tests for session context with HTTP transport -- `tests/e2e/dynamicMode.e2e.test.ts` - E2E tests for DYNAMIC mode and session context +- `tests/sessionContext.integration.test.ts` - Integration tests for session context +- `tests/e2e/dynamicMode.e2e.test.ts` - E2E tests for DYNAMIC mode - `tests/e2e/staticMode.e2e.test.ts` - E2E tests for STATIC mode - `tests/e2e/permissionBased.e2e.test.ts` - E2E tests for permission-based servers diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index 229d876..85cfaee 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -86,4 +86,4 @@ notifyToolsChanged() (unless skipNotification) - `src/types/AGENTS.md` - ExposurePolicy, ToolingErrorCode --- -*Keep this Intent Node updated when modifying core orchestration. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying core orchestration. See root AGENTS.md for maintenance guidelines.* diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index 8e5a10c..22a177b 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -102,4 +102,4 @@ definePermissionAwareEndpoint({...}) - `src/server/AGENTS.md` - How transport is configured --- -*Keep this Intent Node updated when modifying HTTP transport. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying HTTP transport. See root AGENTS.md for maintenance guidelines.* diff --git a/src/mode/AGENTS.md b/src/mode/AGENTS.md index f798375..6b7e73d 100644 --- a/src/mode/AGENTS.md +++ b/src/mode/AGENTS.md @@ -58,4 +58,4 @@ Return flattened McpToolDefinition[] - `src/types/AGENTS.md` - ModuleLoader type definition --- -*Keep this Intent Node updated when modifying mode resolution. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying mode resolution. See root AGENTS.md for maintenance guidelines.* diff --git a/src/permissions/AGENTS.md b/src/permissions/AGENTS.md index d263e74..adba01e 100644 --- a/src/permissions/AGENTS.md +++ b/src/permissions/AGENTS.md @@ -88,4 +88,4 @@ Return bundle (cached for reuse) - `src/types/AGENTS.md` - PermissionConfig type --- -*Keep this Intent Node updated when modifying permissions. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying permissions. See root AGENTS.md for maintenance guidelines.* diff --git a/src/server/AGENTS.md b/src/server/AGENTS.md index 23f2f4e..8c1cb1b 100644 --- a/src/server/AGENTS.md +++ b/src/server/AGENTS.md @@ -104,4 +104,4 @@ Always STATIC per client, no meta-tools - `src/permissions/AGENTS.md` - Permission resolution --- -*Keep this Intent Node updated when modifying server creation. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying server creation. See root AGENTS.md for maintenance guidelines.* diff --git a/src/session/AGENTS.md b/src/session/AGENTS.md index 488bab3..3831f1b 100644 --- a/src/session/AGENTS.md +++ b/src/session/AGENTS.md @@ -73,4 +73,4 @@ Return {context, cacheKeySuffix} - `src/types/AGENTS.md` - SessionContextConfig type definition --- -*Keep this Intent Node updated when modifying session handling. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying session handling. See root AGENTS.md for maintenance guidelines.* diff --git a/src/types/AGENTS.md b/src/types/AGENTS.md index 6d2f37d..eb9b67e 100644 --- a/src/types/AGENTS.md +++ b/src/types/AGENTS.md @@ -59,4 +59,4 @@ Defines all TypeScript interfaces, types, and contracts for the Toolception syst - `src/core/AGENTS.md` - How types are used in orchestration --- -*Keep this Intent Node updated when modifying types. See root CLAUDE.md for maintenance guidelines.* +*Keep this Intent Node updated when modifying types. See root AGENTS.md for maintenance guidelines.* From 8ac427b4d5f9510c3bf16f20066f0b338ffa6132 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 17:46:53 +0200 Subject: [PATCH 04/11] chore: implement builder pattern --- src/core/DynamicToolManager.ts | 41 ++-- src/core/ServerOrchestrator.ts | 76 +++--- src/core/ToolRegistry.ts | 14 +- src/core/core.types.ts | 28 +++ src/http/FastifyTransport.ts | 76 +++--- .../{customEndpoints.ts => http.types.ts} | 139 +++-------- ...{endpointRegistration.ts => http.utils.ts} | 131 ++++++++-- src/index.ts | 8 +- src/mode/ModeResolver.ts | 31 +-- src/mode/ModuleResolver.ts | 23 +- src/mode/mode.types.ts | 26 ++ .../PermissionAwareFastifyTransport.ts | 93 ++----- src/permissions/PermissionResolver.ts | 62 ++--- .../createPermissionAwareBundle.ts | 131 ---------- src/permissions/permissions.types.ts | 63 +++++ src/permissions/permissions.utils.ts | 227 ++++++++++++++++++ src/permissions/validatePermissionConfig.ts | 119 --------- src/server/createMcpServer.ts | 119 +++------ src/server/createPermissionBasedMcpServer.ts | 183 ++++---------- src/server/server.types.ts | 45 ++++ src/server/server.utils.ts | 12 + src/session/ClientResourceCache.ts | 47 +--- src/session/SessionContextResolver.ts | 87 +------ src/session/session.types.ts | 31 +++ ...ssionContextConfig.ts => session.utils.ts} | 21 -- src/types/index.ts | 2 +- tests/createPermissionAwareBundle.test.ts | 6 +- tests/customEndpoints.test.ts | 3 +- tests/permissionAwareFastifyTransport.test.ts | 2 +- tests/validatePermissionConfig.test.ts | 2 +- tests/validateSessionContextConfig.test.ts | 2 +- 31 files changed, 863 insertions(+), 987 deletions(-) create mode 100644 src/core/core.types.ts rename src/http/{customEndpoints.ts => http.types.ts} (63%) rename src/http/{endpointRegistration.ts => http.utils.ts} (60%) create mode 100644 src/mode/mode.types.ts delete mode 100644 src/permissions/createPermissionAwareBundle.ts create mode 100644 src/permissions/permissions.types.ts create mode 100644 src/permissions/permissions.utils.ts delete mode 100644 src/permissions/validatePermissionConfig.ts create mode 100644 src/server/server.types.ts create mode 100644 src/server/server.utils.ts create mode 100644 src/session/session.types.ts rename src/session/{validateSessionContextConfig.ts => session.utils.ts} (82%) diff --git a/src/core/DynamicToolManager.ts b/src/core/DynamicToolManager.ts index 9144c4b..33c4955 100644 --- a/src/core/DynamicToolManager.ts +++ b/src/core/DynamicToolManager.ts @@ -7,15 +7,7 @@ import type { } from "../types/index.js"; import { ModuleResolver } from "../mode/ModuleResolver.js"; import { ToolRegistry } from "./ToolRegistry.js"; - -export interface DynamicToolManagerOptions { - server: McpServer; - resolver: ModuleResolver; - context?: unknown; - onToolsListChanged?: () => Promise | void; - exposurePolicy?: ExposurePolicy; - toolRegistry?: ToolRegistry; -} +import type { DynamicToolManagerOptions } from "./core.types.js"; export class DynamicToolManager { private readonly server: McpServer; @@ -34,14 +26,25 @@ export class DynamicToolManager { this.onToolsListChanged = options.onToolsListChanged; this.exposurePolicy = options.exposurePolicy; this.toolRegistry = - options.toolRegistry ?? new ToolRegistry({ namespaceWithToolset: true }); + options.toolRegistry ?? ToolRegistry.builder().namespaceWithToolset(true).build(); + } + + static builder() { + const opts: Partial = {}; + const builder = { + server(value: McpServer) { opts.server = value; return builder; }, + resolver(value: ModuleResolver) { opts.resolver = value; return builder; }, + context(value: unknown) { opts.context = value; return builder; }, + onToolsListChanged(value: () => Promise | void) { opts.onToolsListChanged = value; return builder; }, + exposurePolicy(value: ExposurePolicy) { opts.exposurePolicy = value; return builder; }, + toolRegistry(value: ToolRegistry) { opts.toolRegistry = value; return builder; }, + build() { return new DynamicToolManager(opts as DynamicToolManagerOptions); }, + }; + return builder; } /** - * Sends a tool list change notification if configured. - * Logs warnings on failure instead of throwing. * @returns Promise that resolves when notification is sent (or skipped) - * @private */ private async notifyToolsChanged(): Promise { if (!this.onToolsListChanged) return; @@ -70,9 +73,8 @@ export class DynamicToolManager { /** * Enables a single toolset by name. - * Validates the toolset, checks exposure policies, resolves tools, and registers them. * @param toolsetName - The name of the toolset to enable - * @param skipNotification - If true, skips the tool list change notification (for batch operations) + * @param skipNotification - If true, skips the tool list change notification * @returns Result object with success status and message */ public async enableToolset( @@ -155,10 +157,8 @@ export class DynamicToolManager { } /** - * Checks if a toolset is allowed by the exposure policy. * @param toolsetName - The sanitized toolset name to check * @returns Object indicating if allowed and reason message if not - * @private */ private checkExposurePolicy(toolsetName: string): { allowed: boolean; @@ -199,10 +199,8 @@ export class DynamicToolManager { } /** - * Registers a single tool with the MCP server. * @param tool - The tool definition to register * @param toolsetKey - The toolset key for tracking - * @private */ private registerSingleTool(tool: McpToolDefinition, toolsetKey: string): void { // Only pass annotations if they exist and are not empty @@ -234,8 +232,6 @@ export class DynamicToolManager { } /** - * Disables a toolset by name. - * Note: Due to MCP limitations, tools remain registered but the toolset is marked inactive. * @param toolsetName - The name of the toolset to disable * @returns Result object with success status and message */ @@ -286,8 +282,6 @@ export class DynamicToolManager { } /** - * Enables multiple toolsets in a batch operation. - * Sends a single notification after all toolsets are processed. * @param toolsetNames - Array of toolset names to enable * @returns Result object with overall success status and individual results */ @@ -340,7 +334,6 @@ export class DynamicToolManager { } /** - * Enables all available toolsets in a batch operation. * @returns Result object with overall success status and individual results */ public async enableAllToolsets(): Promise<{ diff --git a/src/core/ServerOrchestrator.ts b/src/core/ServerOrchestrator.ts index 2e386e3..086736e 100644 --- a/src/core/ServerOrchestrator.ts +++ b/src/core/ServerOrchestrator.ts @@ -10,17 +10,7 @@ import type { ToolSetCatalog, } from "../types/index.js"; import { ToolRegistry } from "./ToolRegistry.js"; - -export interface ServerOrchestratorOptions { - server: McpServer; - catalog: ToolSetCatalog; - moduleLoaders?: Record; - exposurePolicy?: ExposurePolicy; - context?: unknown; - notifyToolsListChanged?: () => Promise | void; - startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; - registerMetaTools?: boolean; -} +import type { ServerOrchestratorOptions } from "./core.types.js"; export class ServerOrchestrator { private readonly mode: Exclude; @@ -31,26 +21,27 @@ export class ServerOrchestrator { private initError: Error | null = null; constructor(options: ServerOrchestratorOptions) { - this.toolsetValidator = new ToolsetValidator(); + this.toolsetValidator = ToolsetValidator.builder().build(); const startup = options.startup ?? {}; const resolved = this.resolveStartupConfig(startup, options.catalog); this.mode = resolved.mode; - this.resolver = new ModuleResolver({ - catalog: options.catalog, - moduleLoaders: options.moduleLoaders, - }); - const toolRegistry = new ToolRegistry({ - namespaceWithToolset: - options.exposurePolicy?.namespaceToolsWithSetKey ?? true, - }); - this.manager = new DynamicToolManager({ - server: options.server, - resolver: this.resolver, - context: options.context, - onToolsListChanged: options.notifyToolsListChanged, - exposurePolicy: options.exposurePolicy, - toolRegistry, - }); + this.resolver = ModuleResolver.builder() + .catalog(options.catalog) + .moduleLoaders(options.moduleLoaders ?? {}) + .build(); + const toolRegistry = ToolRegistry.builder() + .namespaceWithToolset( + options.exposurePolicy?.namespaceToolsWithSetKey ?? true + ) + .build(); + this.manager = DynamicToolManager.builder() + .server(options.server) + .resolver(this.resolver) + .context(options.context) + .onToolsListChanged(options.notifyToolsListChanged as () => Promise | void) + .exposurePolicy(options.exposurePolicy as ExposurePolicy) + .toolRegistry(toolRegistry) + .build(); // Register meta-tools only if requested (default true) if (options.registerMetaTools !== false) { @@ -62,12 +53,25 @@ export class ServerOrchestrator { this.initPromise = this.initializeToolsets(initial); } + static builder() { + const opts: Partial = {}; + const builder = { + server(value: McpServer) { opts.server = value; return builder; }, + catalog(value: ToolSetCatalog) { opts.catalog = value; return builder; }, + moduleLoaders(value: Record) { opts.moduleLoaders = value; return builder; }, + exposurePolicy(value: ExposurePolicy) { opts.exposurePolicy = value; return builder; }, + context(value: unknown) { opts.context = value; return builder; }, + notifyToolsListChanged(value: () => Promise | void) { opts.notifyToolsListChanged = value; return builder; }, + startup(value: { mode?: Exclude; toolsets?: string[] | "ALL" }) { opts.startup = value; return builder; }, + registerMetaTools(value: boolean) { opts.registerMetaTools = value; return builder; }, + build() { return new ServerOrchestrator(opts as ServerOrchestratorOptions); }, + }; + return builder; + } + /** - * Initializes toolsets asynchronously during construction. - * Stores any errors for later retrieval via ensureReady(). * @param initial - The toolsets to initialize or "ALL" * @returns Promise that resolves when initialization is complete - * @private */ private async initializeToolsets( initial: string[] | "ALL" | undefined @@ -85,11 +89,6 @@ export class ServerOrchestrator { } } - /** - * Waits for the orchestrator to be fully initialized. - * Call this before using the orchestrator to ensure all toolsets are loaded. - * @throws {Error} If initialization failed - */ public async ensureReady(): Promise { await this.initPromise; if (this.initError) { @@ -97,11 +96,6 @@ export class ServerOrchestrator { } } - /** - * Checks if the orchestrator has finished initialization. - * Does not throw on error - use ensureReady() for that. - * @returns Promise that resolves to true if ready, false if initialization failed - */ public async isReady(): Promise { await this.initPromise; return this.initError === null; diff --git a/src/core/ToolRegistry.ts b/src/core/ToolRegistry.ts index eb7a96f..f9f9936 100644 --- a/src/core/ToolRegistry.ts +++ b/src/core/ToolRegistry.ts @@ -1,9 +1,6 @@ import type { McpToolDefinition } from "../types/index.js"; import { ToolingError } from "../errors/ToolingError.js"; - -export interface ToolRegistryOptions { - namespaceWithToolset?: boolean; -} +import type { ToolRegistryOptions } from "./core.types.js"; export class ToolRegistry { private readonly options: Required; @@ -16,6 +13,15 @@ export class ToolRegistry { }; } + static builder() { + const opts: ToolRegistryOptions = {}; + const builder = { + namespaceWithToolset(value: boolean) { opts.namespaceWithToolset = value; return builder; }, + build() { return new ToolRegistry(opts); }, + }; + return builder; + } + public getSafeName(toolsetKey: string, toolName: string): string { if (!this.options.namespaceWithToolset) return toolName; if (toolName.startsWith(`${toolsetKey}.`)) return toolName; diff --git a/src/core/core.types.ts b/src/core/core.types.ts new file mode 100644 index 0000000..dae6528 --- /dev/null +++ b/src/core/core.types.ts @@ -0,0 +1,28 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ExposurePolicy, ModuleLoader, Mode, ToolSetCatalog } from "../types/index.js"; +import type { ModuleResolver } from "../mode/ModuleResolver.js"; +import type { ToolRegistry } from "./ToolRegistry.js"; + +export interface ToolRegistryOptions { + namespaceWithToolset?: boolean; +} + +export interface DynamicToolManagerOptions { + server: McpServer; + resolver: ModuleResolver; + context?: unknown; + onToolsListChanged?: () => Promise | void; + exposurePolicy?: ExposurePolicy; + toolRegistry?: ToolRegistry; +} + +export interface ServerOrchestratorOptions { + server: McpServer; + catalog: ToolSetCatalog; + moduleLoaders?: Record; + exposurePolicy?: ExposurePolicy; + context?: unknown; + notifyToolsListChanged?: () => Promise | void; + startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; + registerMetaTools?: boolean; +} diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index 51cc117..da1a4b5 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -14,38 +14,18 @@ import type { SessionRequestContext } from "../types/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CustomEndpointDefinition } from "./customEndpoints.js"; -import { registerCustomEndpoints } from "./endpointRegistration.js"; +import type { + FastifyTransportOptions, + CreateBundleCallback, + CustomEndpointDefinition, +} from "./http.types.js"; +import { registerCustomEndpoints } from "./http.utils.js"; const mcpClientIdSchema = z .string({ message: "Missing required mcp-client-id header" }) .trim() .min(1, "mcp-client-id header must not be empty"); -export interface FastifyTransportOptions { - host?: string; - port?: number; - basePath?: string; // e.g. "/" or "/api" - cors?: boolean; - logger?: boolean; - // Optional DI: provide a Fastify instance (e.g., for tests). If provided, start() will not listen. - app?: FastifyInstance; - /** - * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. - * Allows adding REST-like endpoints with Zod validation and type inference. - */ - customEndpoints?: CustomEndpointDefinition[]; -} - -/** - * Callback type for creating a server bundle. - * Accepts an optional merged context for per-session context support. - */ -export type CreateBundleCallback = (mergedContext?: unknown) => { - server: McpServer; - orchestrator: ServerOrchestrator; -}; - export class FastifyTransport { private readonly options: { host: string; @@ -99,6 +79,31 @@ export class FastifyTransport { this.configSchema = configSchema; } + static builder() { + let _defaultManager: DynamicToolManager; + let _createBundle: CreateBundleCallback; + const opts: FastifyTransportOptions = {}; + let _configSchema: object | undefined; + let _sessionContextResolver: SessionContextResolver | undefined; + let _baseContext: unknown; + const builder = { + defaultManager(value: DynamicToolManager) { _defaultManager = value; return builder; }, + createBundle(value: CreateBundleCallback) { _createBundle = value; return builder; }, + host(value: string) { opts.host = value; return builder; }, + port(value: number) { opts.port = value; return builder; }, + basePath(value: string) { opts.basePath = value; return builder; }, + cors(value: boolean) { opts.cors = value; return builder; }, + logger(value: boolean) { opts.logger = value; return builder; }, + app(value: FastifyInstance) { opts.app = value; return builder; }, + customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; }, + configSchema(value: object) { _configSchema = value; return builder; }, + sessionContextResolver(value: SessionContextResolver) { _sessionContextResolver = value; return builder; }, + baseContext(value: unknown) { _baseContext = value; return builder; }, + build() { return new FastifyTransport(_defaultManager, _createBundle, opts, _configSchema, _sessionContextResolver, _baseContext); }, + }; + return builder; + } + public async start(): Promise { if (this.app) return; const app = this.options.app ?? Fastify({ logger: this.options.logger }); @@ -301,10 +306,6 @@ export class FastifyTransport { this.app = app; } - /** - * Stops the Fastify server and cleans up all resources. - * Closes all client sessions and clears the cache. - */ public async stop(): Promise { if (!this.app) return; @@ -318,10 +319,7 @@ export class FastifyTransport { } /** - * Cleans up resources associated with a client bundle. - * Closes all sessions within the bundle. * @param bundle - The client bundle to clean up - * @private */ private cleanupBundle(bundle: { server: McpServer; @@ -343,14 +341,9 @@ export class FastifyTransport { } /** - * Resolves the session context and generates a cache key for the request. - * If a session context resolver is configured, it extracts query parameters - * and merges session-specific context with the base context. - * * @param req - The Fastify request * @param clientId - The client identifier * @returns Object with cache key and merged context - * @private */ private resolveSessionContext( req: FastifyRequest, @@ -390,12 +383,8 @@ export class FastifyTransport { } /** - * Extracts headers from a Fastify request as a Record. - * Normalizes header names to lowercase. - * * @param req - The Fastify request * @returns Headers as a string record - * @private */ private extractHeaders(req: FastifyRequest): Record { const headers: Record = {}; @@ -410,11 +399,8 @@ export class FastifyTransport { } /** - * Extracts query parameters from a Fastify request as a Record. - * * @param req - The Fastify request * @returns Query parameters as a string record - * @private */ private extractQuery(req: FastifyRequest): Record { const query: Record = {}; diff --git a/src/http/customEndpoints.ts b/src/http/http.types.ts similarity index 63% rename from src/http/customEndpoints.ts rename to src/http/http.types.ts index 0c1d260..dfa3c71 100644 --- a/src/http/customEndpoints.ts +++ b/src/http/http.types.ts @@ -1,4 +1,31 @@ import { z } from "zod"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; +import type { SessionContextResolver } from "../session/SessionContextResolver.js"; + +export interface FastifyTransportOptions { + host?: string; + port?: number; + basePath?: string; + cors?: boolean; + logger?: boolean; + app?: FastifyInstance; + /** + * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. + * Allows adding REST-like endpoints with Zod validation and type inference. + */ + customEndpoints?: CustomEndpointDefinition[]; +} + +/** + * Callback type for creating a server bundle. + * Accepts an optional merged context for per-session context support. + */ +export type CreateBundleCallback = (mergedContext?: unknown) => { + server: McpServer; + orchestrator: ServerOrchestrator; +}; /** * Supported HTTP methods for custom endpoints @@ -215,108 +242,14 @@ export interface EndpointErrorResponse { } /** - * Helper function to create type-safe custom endpoints with automatic type inference. - * Provides better IntelliSense and type checking for endpoint definitions. - * - * @template TBody - Zod schema for request body - * @template TQuery - Zod schema for query parameters - * @template TParams - Zod schema for path parameters - * @template TResponse - Zod schema for response - * - * @param definition - Endpoint definition with schemas and handler - * @returns The same endpoint definition with full type inference - * - * @example - * ```typescript - * import { z } from "zod"; - * import { defineEndpoint } from "toolception"; - * - * const getUsersEndpoint = defineEndpoint({ - * method: "GET", - * path: "/users", - * querySchema: z.object({ - * limit: z.coerce.number().int().positive().default(10), - * role: z.enum(["admin", "user"]).optional(), - * }), - * responseSchema: z.object({ - * users: z.array(z.object({ - * id: z.string(), - * name: z.string(), - * })), - * total: z.number(), - * }), - * handler: async (req) => { - * // req.query is fully typed: { limit: number, role?: "admin" | "user" } - * const { limit, role } = req.query; - * - * return { - * users: [{ id: "1", name: "Alice" }], - * total: 1, - * }; - * }, - * }); - * ``` + * Options for registering custom endpoints */ -export function defineEndpoint< - TBody extends z.ZodTypeAny = z.ZodNever, - TQuery extends z.ZodTypeAny = z.ZodNever, - TParams extends z.ZodTypeAny = z.ZodNever, - TResponse extends z.ZodTypeAny = z.ZodAny ->( - definition: CustomEndpointDefinition -): CustomEndpointDefinition { - return definition; -} - -/** - * Helper function to create permission-aware custom endpoints for permission-based servers. - * Similar to defineEndpoint but with access to permission context in the handler. - * - * @template TBody - Zod schema for request body - * @template TQuery - Zod schema for query parameters - * @template TParams - Zod schema for path parameters - * @template TResponse - Zod schema for response - * - * @param definition - Endpoint definition with permission-aware handler - * @returns Endpoint definition compatible with permission-based servers - * - * @example - * ```typescript - * import { definePermissionAwareEndpoint } from "toolception"; - * - * const statsEndpoint = definePermissionAwareEndpoint({ - * method: "GET", - * path: "/my-permissions", - * responseSchema: z.object({ - * toolsets: z.array(z.string()), - * count: z.number(), - * }), - * handler: async (req) => { - * // req.allowedToolsets and req.failedToolsets are available - * return { - * toolsets: req.allowedToolsets, - * count: req.allowedToolsets.length, - * }; - * }, - * }); - * ``` - */ -export function definePermissionAwareEndpoint< - TBody extends z.ZodTypeAny = z.ZodNever, - TQuery extends z.ZodTypeAny = z.ZodNever, - TParams extends z.ZodTypeAny = z.ZodNever, - TResponse extends z.ZodTypeAny = z.ZodAny ->(definition: { - method: HttpMethod; - path: string; - bodySchema?: TBody; - querySchema?: TQuery; - paramsSchema?: TParams; - responseSchema?: TResponse; - handler: PermissionAwareEndpointHandler; - description?: string; -}): CustomEndpointDefinition { - // Internal conversion: permission-aware handler is compatible with standard handler - // The permission fields will be injected by the registration logic - return definition as any; +export interface RegisterCustomEndpointsOptions { + /** + * Optional function to extract additional context for each request. + * Used by permission-aware transport to inject permission data. + */ + contextExtractor?: ( + req: FastifyRequest + ) => Promise> | Record; } diff --git a/src/http/endpointRegistration.ts b/src/http/http.utils.ts similarity index 60% rename from src/http/endpointRegistration.ts rename to src/http/http.utils.ts index a790035..b02edc5 100644 --- a/src/http/endpointRegistration.ts +++ b/src/http/http.utils.ts @@ -5,21 +5,125 @@ import type { CustomEndpointDefinition, CustomEndpointRequest, EndpointErrorResponse, -} from "./customEndpoints.js"; + HttpMethod, + PermissionAwareEndpointHandler, + PermissionAwareEndpointRequest, + RegisterCustomEndpointsOptions, +} from "./http.types.js"; + +// --- defineEndpoint (from customEndpoints.ts) --- + +/** + * Helper function to create type-safe custom endpoints with automatic type inference. + * Provides better IntelliSense and type checking for endpoint definitions. + * + * @template TBody - Zod schema for request body + * @template TQuery - Zod schema for query parameters + * @template TParams - Zod schema for path parameters + * @template TResponse - Zod schema for response + * + * @param definition - Endpoint definition with schemas and handler + * @returns The same endpoint definition with full type inference + * + * @example + * ```typescript + * import { z } from "zod"; + * import { defineEndpoint } from "toolception"; + * + * const getUsersEndpoint = defineEndpoint({ + * method: "GET", + * path: "/users", + * querySchema: z.object({ + * limit: z.coerce.number().int().positive().default(10), + * role: z.enum(["admin", "user"]).optional(), + * }), + * responseSchema: z.object({ + * users: z.array(z.object({ + * id: z.string(), + * name: z.string(), + * })), + * total: z.number(), + * }), + * handler: async (req) => { + * // req.query is fully typed: { limit: number, role?: "admin" | "user" } + * const { limit, role } = req.query; + * + * return { + * users: [{ id: "1", name: "Alice" }], + * total: 1, + * }; + * }, + * }); + * ``` + */ +export function defineEndpoint< + TBody extends z.ZodTypeAny = z.ZodNever, + TQuery extends z.ZodTypeAny = z.ZodNever, + TParams extends z.ZodTypeAny = z.ZodNever, + TResponse extends z.ZodTypeAny = z.ZodAny +>( + definition: CustomEndpointDefinition +): CustomEndpointDefinition { + return definition; +} + +// --- definePermissionAwareEndpoint (from customEndpoints.ts) --- /** - * Options for registering custom endpoints + * Helper function to create permission-aware custom endpoints for permission-based servers. + * Similar to defineEndpoint but with access to permission context in the handler. + * + * @template TBody - Zod schema for request body + * @template TQuery - Zod schema for query parameters + * @template TParams - Zod schema for path parameters + * @template TResponse - Zod schema for response + * + * @param definition - Endpoint definition with permission-aware handler + * @returns Endpoint definition compatible with permission-based servers + * + * @example + * ```typescript + * import { definePermissionAwareEndpoint } from "toolception"; + * + * const statsEndpoint = definePermissionAwareEndpoint({ + * method: "GET", + * path: "/my-permissions", + * responseSchema: z.object({ + * toolsets: z.array(z.string()), + * count: z.number(), + * }), + * handler: async (req) => { + * // req.allowedToolsets and req.failedToolsets are available + * return { + * toolsets: req.allowedToolsets, + * count: req.allowedToolsets.length, + * }; + * }, + * }); + * ``` */ -export interface RegisterCustomEndpointsOptions { - /** - * Optional function to extract additional context for each request. - * Used by permission-aware transport to inject permission data. - */ - contextExtractor?: ( - req: FastifyRequest - ) => Promise> | Record; +export function definePermissionAwareEndpoint< + TBody extends z.ZodTypeAny = z.ZodNever, + TQuery extends z.ZodTypeAny = z.ZodNever, + TParams extends z.ZodTypeAny = z.ZodNever, + TResponse extends z.ZodTypeAny = z.ZodAny +>(definition: { + method: HttpMethod; + path: string; + bodySchema?: TBody; + querySchema?: TQuery; + paramsSchema?: TParams; + responseSchema?: TResponse; + handler: PermissionAwareEndpointHandler; + description?: string; +}): CustomEndpointDefinition { + // Internal conversion: permission-aware handler is compatible with standard handler + // The permission fields will be injected by the registration logic + return definition as any; } +// --- registerCustomEndpoints (from endpointRegistration.ts) --- + /** * Registers custom endpoints on a Fastify instance. * Handles Zod validation, error responses, and type-safe request mapping. @@ -178,15 +282,12 @@ export function registerCustomEndpoints( /** * Creates a standardized validation error response. - * Returns 400 status code with detailed Zod validation errors. - * * @param reply - Fastify reply object - * @param field - The field that failed validation (body, query, or params) + * @param field - The field that failed validation * @param error - Zod validation error * @returns Formatted error response - * @private */ -function createValidationError( +export function createValidationError( reply: FastifyReply, field: string, error: z.ZodError diff --git a/src/index.ts b/src/index.ts index 9db4886..8c341f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ // Standard MCP server creation export { createMcpServer } from "./server/createMcpServer.js"; -export type { CreateMcpServerOptions } from "./server/createMcpServer.js"; +export type { CreateMcpServerOptions } from "./server/server.types.js"; // Permission-based MCP server creation (separate API for per-client toolset access control) export { createPermissionBasedMcpServer } from "./server/createPermissionBasedMcpServer.js"; @@ -23,7 +23,7 @@ export type { // Session context support export { SessionContextResolver } from "./session/SessionContextResolver.js"; -export type { SessionContextResult } from "./session/SessionContextResolver.js"; +export type { SessionContextResult } from "./session/session.types.js"; // Custom endpoint support export type { @@ -34,9 +34,9 @@ export type { PermissionAwareEndpointHandler, HttpMethod, EndpointErrorResponse, -} from "./http/customEndpoints.js"; +} from "./http/http.types.js"; export { defineEndpoint, definePermissionAwareEndpoint, -} from "./http/customEndpoints.js"; +} from "./http/http.utils.js"; diff --git a/src/mode/ModeResolver.ts b/src/mode/ModeResolver.ts index e9ca320..6966e61 100644 --- a/src/mode/ModeResolver.ts +++ b/src/mode/ModeResolver.ts @@ -1,22 +1,6 @@ import type { Mode, ToolSetCatalog } from "../types/index.js"; - -interface ModeResolverKeys { - dynamic?: string[]; // keys that, when present/true, enable dynamic mode - toolsets?: string[]; // keys that carry comma-separated toolsets -} - -interface ModeResolverOptions { - keys?: ModeResolverKeys; -} - -const DEFAULT_KEYS: Required = { - dynamic: [ - "dynamic-tool-discovery", - "dynamicToolDiscovery", - "DYNAMIC_TOOL_DISCOVERY", - ], - toolsets: ["tool-sets", "toolSets", "FMP_TOOL_SETS"], -}; +import type { ModeResolverKeys, ModeResolverOptions } from "./mode.types.js"; +import { DEFAULT_KEYS } from "./mode.types.js"; export class ToolsetValidator { private readonly keys: Required; @@ -28,6 +12,15 @@ export class ToolsetValidator { }; } + static builder() { + const opts: ModeResolverOptions = {}; + const builder = { + keys(value: ModeResolverKeys) { opts.keys = value; return builder; }, + build() { return new ToolsetValidator(opts); }, + }; + return builder; + } + public resolveMode( env?: Record, args?: Record @@ -117,8 +110,6 @@ export class ToolsetValidator { } /** - * Validates and retrieves modules for a set of toolsets. - * Note: A toolset with only direct tools (no modules) is valid and returns an empty modules array. * @param toolsetNames - Array of toolset names to validate * @param catalog - The toolset catalog to validate against * @returns Validation result with modules array if valid diff --git a/src/mode/ModuleResolver.ts b/src/mode/ModuleResolver.ts index b41c69e..2360f86 100644 --- a/src/mode/ModuleResolver.ts +++ b/src/mode/ModuleResolver.ts @@ -4,17 +4,8 @@ import type { McpToolDefinition, 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; -} +import type { ModuleResolverOptions } from "./mode.types.js"; +import { RESERVED_TOOLSET_KEYS } from "./mode.types.js"; export class ModuleResolver { private readonly catalog: ToolSetCatalog; @@ -33,6 +24,16 @@ export class ModuleResolver { this.moduleLoaders = options.moduleLoaders ?? {}; } + static builder() { + const opts: Partial = {}; + const builder = { + catalog(value: ToolSetCatalog) { opts.catalog = value; return builder; }, + moduleLoaders(value: Record) { opts.moduleLoaders = value; return builder; }, + build() { return new ModuleResolver(opts as ModuleResolverOptions); }, + }; + return builder; + } + public getAvailableToolsets(): string[] { return Object.keys(this.catalog); } diff --git a/src/mode/mode.types.ts b/src/mode/mode.types.ts new file mode 100644 index 0000000..b429fdb --- /dev/null +++ b/src/mode/mode.types.ts @@ -0,0 +1,26 @@ +import type { ToolSetCatalog, ModuleLoader } from "../types/index.js"; + +export interface ModeResolverKeys { + dynamic?: string[]; + toolsets?: string[]; +} + +export interface ModeResolverOptions { + keys?: ModeResolverKeys; +} + +export const DEFAULT_KEYS: Required = { + dynamic: [ + "dynamic-tool-discovery", + "dynamicToolDiscovery", + "DYNAMIC_TOOL_DISCOVERY", + ], + toolsets: ["tool-sets", "toolSets", "FMP_TOOL_SETS"], +}; + +export const RESERVED_TOOLSET_KEYS = ["_meta"]; + +export interface ModuleResolverOptions { + catalog: ToolSetCatalog; + moduleLoaders?: Record; +} diff --git a/src/permissions/PermissionAwareFastifyTransport.ts b/src/permissions/PermissionAwareFastifyTransport.ts index d271120..2541572 100644 --- a/src/permissions/PermissionAwareFastifyTransport.ts +++ b/src/permissions/PermissionAwareFastifyTransport.ts @@ -15,38 +15,16 @@ import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import type { ClientRequestContext, PermissionAwareBundle, -} from "./createPermissionAwareBundle.js"; -import type { CustomEndpointDefinition } from "../http/customEndpoints.js"; -import { registerCustomEndpoints } from "../http/endpointRegistration.js"; + PermissionAwareFastifyTransportOptions, +} from "./permissions.types.js"; +import type { CustomEndpointDefinition } from "../http/http.types.js"; +import { registerCustomEndpoints } from "../http/http.utils.js"; const mcpClientIdSchema = z .string({ message: "Missing required mcp-client-id header" }) .trim() .min(1, "mcp-client-id header must not be empty"); -export interface PermissionAwareFastifyTransportOptions { - host?: string; - port?: number; - basePath?: string; - cors?: boolean; - logger?: boolean; - app?: FastifyInstance; - /** - * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. - * Allows adding REST-like endpoints with Zod validation and type inference. - * Handlers receive permission context (allowedToolsets, failedToolsets). - */ - customEndpoints?: CustomEndpointDefinition[]; -} - -/** - * Enhanced Fastify transport that supports permission-based toolset access. - * Integrates with PermissionResolver to enforce per-client toolset permissions. - * - * This transport extracts client context from requests and passes it to the - * permission-aware bundle creator, ensuring each client receives only their - * authorized toolsets while maintaining session management and caching. - */ export class PermissionAwareFastifyTransport { private readonly options: { host: string; @@ -78,13 +56,6 @@ export class PermissionAwareFastifyTransport { }, }); - /** - * Creates a new PermissionAwareFastifyTransport instance. - * @param defaultManager - Default tool manager for status endpoints - * @param createPermissionAwareBundle - Function to create permission-aware bundles - * @param options - Transport configuration options - * @param configSchema - Optional JSON schema for configuration discovery - */ constructor( defaultManager: DynamicToolManager, createPermissionAwareBundle: ( @@ -107,10 +78,27 @@ export class PermissionAwareFastifyTransport { this.configSchema = configSchema; } - /** - * Starts the Fastify server and registers all MCP endpoints. - * Sets up routes for health checks, tool status, and MCP protocol handling. - */ + static builder() { + let _defaultManager: DynamicToolManager; + let _createPermissionAwareBundle: (context: ClientRequestContext) => Promise; + const opts: PermissionAwareFastifyTransportOptions = {}; + let _configSchema: object | undefined; + const builder = { + defaultManager(value: DynamicToolManager) { _defaultManager = value; return builder; }, + createPermissionAwareBundle(value: (context: ClientRequestContext) => Promise) { _createPermissionAwareBundle = value; return builder; }, + host(value: string) { opts.host = value; return builder; }, + port(value: number) { opts.port = value; return builder; }, + basePath(value: string) { opts.basePath = value; return builder; }, + cors(value: boolean) { opts.cors = value; return builder; }, + logger(value: boolean) { opts.logger = value; return builder; }, + app(value: FastifyInstance) { opts.app = value; return builder; }, + customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; }, + configSchema(value: object) { _configSchema = value; return builder; }, + build() { return new PermissionAwareFastifyTransport(_defaultManager, _createPermissionAwareBundle, opts, _configSchema); }, + }; + return builder; + } + public async start(): Promise { if (this.app) return; const app = this.options.app ?? Fastify({ logger: this.options.logger }); @@ -163,10 +151,6 @@ export class PermissionAwareFastifyTransport { this.app = app; } - /** - * Stops the Fastify server and cleans up all resources. - * Closes all client sessions and clears the cache. - */ public async stop(): Promise { if (!this.app) return; @@ -180,10 +164,7 @@ export class PermissionAwareFastifyTransport { } /** - * Cleans up resources associated with a client bundle. - * Closes all sessions within the bundle. * @param bundle - The client bundle to clean up - * @private */ #cleanupBundle(bundle: { server: McpServer; @@ -207,40 +188,32 @@ export class PermissionAwareFastifyTransport { } /** - * Normalizes the base path by removing trailing slashes. * @param basePath - The base path to normalize * @returns Normalized base path without trailing slash - * @private */ #normalizeBasePath(basePath: string): string { return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; } /** - * Registers the health check endpoint. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerHealthEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/healthz`, async () => ({ ok: true })); } /** - * Registers the tools status endpoint. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerToolsEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/tools`, async () => this.defaultManager.getStatus()); } /** - * Registers the MCP configuration discovery endpoint. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerConfigDiscoveryEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/.well-known/mcp-config`, async (_req, reply) => { @@ -260,11 +233,8 @@ export class PermissionAwareFastifyTransport { } /** - * Registers the POST /mcp endpoint for JSON-RPC requests. - * Extracts client context, resolves permissions, and handles MCP protocol. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerMcpPostEndpoint(app: FastifyInstance, base: string): void { app.post( @@ -370,10 +340,8 @@ export class PermissionAwareFastifyTransport { } /** - * Registers the GET /mcp endpoint for SSE notifications. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerMcpGetEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { @@ -407,10 +375,8 @@ export class PermissionAwareFastifyTransport { } /** - * Registers the DELETE /mcp endpoint for session termination. * @param app - Fastify instance * @param base - Base path for routes - * @private */ #registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void { app.delete( @@ -461,12 +427,8 @@ export class PermissionAwareFastifyTransport { } /** - * Extracts client context from the request. - * Generates anonymous client ID if header is missing. MCP protocol endpoints - * validate the header separately before calling this. * @param req - Fastify request object * @returns Client request context with ID and headers - * @private */ #extractClientContext(req: FastifyRequest): ClientRequestContext { const clientIdHeader = ( @@ -489,12 +451,9 @@ export class PermissionAwareFastifyTransport { } /** - * Creates a safe error response that doesn't expose unauthorized toolset information. - * Used for permission-related errors to prevent information leakage. * @param message - Generic error message to return to client - * @param code - JSON-RPC error code (default: -32000 for server error) + * @param code - JSON-RPC error code * @returns JSON-RPC error response object - * @private */ #createSafeErrorResponse(message: string = "Access denied", code: number = -32000) { return { diff --git a/src/permissions/PermissionResolver.ts b/src/permissions/PermissionResolver.ts index 2967f81..92a7652 100644 --- a/src/permissions/PermissionResolver.ts +++ b/src/permissions/PermissionResolver.ts @@ -1,18 +1,9 @@ import type { PermissionConfig } from "../types/index.js"; -/** - * Resolves and caches client permissions based on configured permission sources. - * Supports both header-based and config-based permission resolution with caching - * for performance optimization. - */ export class PermissionResolver { private cache = new Map(); private readonly normalizedHeaderName: string; - /** - * Creates a new PermissionResolver instance. - * @param config - The permission configuration defining how permissions are resolved - */ constructor(private config: PermissionConfig) { // Pre-normalize header name to lowercase for case-insensitive matching this.normalizedHeaderName = ( @@ -20,17 +11,22 @@ export class PermissionResolver { ).toLowerCase(); } + static builder() { + const opts: Partial = {}; + const builder = { + source(value: "headers" | "config") { opts.source = value; return builder; }, + headerName(value: string) { opts.headerName = value; return builder; }, + staticMap(value: Record) { opts.staticMap = value; return builder; }, + resolver(value: (clientId: string) => string[]) { opts.resolver = value; return builder; }, + defaultPermissions(value: string[]) { opts.defaultPermissions = value; return builder; }, + build() { return new PermissionResolver(opts as PermissionConfig); }, + }; + return builder; + } + /** - * Resolves permissions for a client based on the configured source. - * Results are cached to improve performance for subsequent requests from the same client. - * Handles all errors gracefully by returning empty permissions on failure. - * - * Note on caching: For header-based permissions, permissions are cached by clientId. - * This means subsequent requests from the same client will use cached permissions, - * even if headers change. Use invalidateCache(clientId) to force re-resolution. - * * @param clientId - The unique identifier for the client - * @param headers - Optional request headers (required for header-based permissions) + * @param headers - Optional request headers * @returns Array of toolset names the client is allowed to access */ resolvePermissions( @@ -78,8 +74,6 @@ export class PermissionResolver { } /** - * Invalidates cached permissions for a specific client. - * Call this when you know a client's permissions have changed. * @param clientId - The client ID to invalidate */ invalidateCache(clientId: string): void { @@ -87,13 +81,8 @@ export class PermissionResolver { } /** - * Parses permissions from request headers. - * Extracts comma-separated toolset names from the configured header. - * Handles malformed headers gracefully by returning empty permissions. - * Uses case-insensitive header lookup per RFC 7230. * @param headers - Request headers containing permission data - * @returns Array of toolset names from headers, or empty array if header is missing/malformed - * @private + * @returns Array of toolset names from headers */ #parseHeaderPermissions(headers?: Record): string[] { if (!headers) { @@ -127,12 +116,9 @@ export class PermissionResolver { } /** - * Finds a header value using case-insensitive key matching. - * HTTP headers are case-insensitive per RFC 7230. * @param headers - The headers object to search * @param normalizedKey - The lowercase key to search for - * @returns The header value if found, undefined otherwise - * @private + * @returns The header value if found */ #findHeaderCaseInsensitive( headers: Record, @@ -152,12 +138,8 @@ export class PermissionResolver { } /** - * Resolves permissions from server-side configuration. - * Tries resolver function first (if provided), then falls back to static map, - * and finally to default permissions. Handles errors gracefully. * @param clientId - The unique identifier for the client * @returns Array of toolset names from configuration - * @private */ #resolveConfigPermissions(clientId: string): string[] { // Try resolver function first (if provided) @@ -182,11 +164,8 @@ export class PermissionResolver { } /** - * Attempts to resolve permissions using the configured resolver function. - * Handles errors gracefully and returns null on failure to allow fallback. * @param clientId - The unique identifier for the client - * @returns Array of toolset names if successful, null if resolver fails or returns invalid data - * @private + * @returns Array of toolset names if successful, null if resolver fails */ #tryResolverFunction(clientId: string): string[] | null { try { @@ -209,11 +188,8 @@ export class PermissionResolver { } /** - * Looks up permissions in the static map configuration. - * Returns null if client is not found to allow fallback to defaults. * @param clientId - The unique identifier for the client * @returns Array of toolset names if found, null if client not in map - * @private */ #lookupStaticMap(clientId: string): string[] | null { const permissions = this.config.staticMap![clientId]; @@ -223,10 +199,6 @@ export class PermissionResolver { return null; } - /** - * Clears the permission cache. - * Useful for cleanup during server shutdown or when permissions need to be refreshed. - */ clearCache(): void { this.cache.clear(); } diff --git a/src/permissions/createPermissionAwareBundle.ts b/src/permissions/createPermissionAwareBundle.ts deleted file mode 100644 index 8fd4de8..0000000 --- a/src/permissions/createPermissionAwareBundle.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; -import type { PermissionResolver } from "./PermissionResolver.js"; - -/** - * Context information extracted from a client request. - * Used to identify the client and resolve their permissions. - */ -export interface ClientRequestContext { - /** - * Unique identifier for the client making the request. - * May be provided via mcp-client-id header or generated as anonymous ID. - */ - clientId: string; - - /** - * Request headers that may contain permission data. - * Used for header-based permission resolution. - */ - headers?: Record; -} - -/** - * Result of permission-aware bundle creation, including the resolved permissions. - */ -export interface PermissionAwareBundle { - /** - * The MCP server instance for this client. - */ - server: McpServer; - - /** - * The orchestrator managing toolsets for this client. - */ - orchestrator: ServerOrchestrator; - - /** - * The resolved permissions (allowed toolsets) for this client. - * Contains only the toolsets that were successfully enabled. - */ - allowedToolsets: string[]; - - /** - * Toolsets that failed to enable (e.g., invalid names). - * Empty if all requested toolsets were enabled successfully. - */ - failedToolsets: string[]; -} - -/** - * Creates a permission-aware bundle creation function that wraps the original - * createBundle function with permission resolution and enforcement. - * - * This function resolves client permissions and passes them to the bundle creator, - * which creates a server with STATIC mode configured to only those toolsets. - * - * @param originalCreateBundle - Bundle creation function that accepts allowed toolsets - * @param permissionResolver - Resolver instance for determining client permissions - * @returns Enhanced bundle creation function that accepts client context - */ -export function createPermissionAwareBundle( - originalCreateBundle: (allowedToolsets: string[]) => { - server: McpServer; - orchestrator: ServerOrchestrator; - }, - permissionResolver: PermissionResolver -) { - /** - * Creates a server bundle with permission-based toolset access control. - * Resolves client permissions and creates a server with those toolsets pre-loaded. - * - * This function is async to ensure toolsets are fully loaded before the server - * is connected to a transport. - * - * @param context - Client request context containing ID and headers - * @returns Promise resolving to server bundle with resolved permissions - * @throws {Error} If all requested toolsets fail to enable - */ - return async ( - context: ClientRequestContext - ): Promise => { - // Resolve permissions for this client - const requestedToolsets = permissionResolver.resolvePermissions( - context.clientId, - context.headers - ); - - // Create bundle with allowed toolsets (STATIC mode pre-loads them) - const bundle = originalCreateBundle(requestedToolsets); - - // Wait for toolsets to be enabled before returning - // This ensures tools are registered before the server connects to transport - const manager = bundle.orchestrator.getManager(); - - const enabledToolsets: string[] = []; - const failedToolsets: string[] = []; - - if (requestedToolsets.length > 0) { - const result = await manager.enableToolsets(requestedToolsets); - - // Collect successful and failed toolsets - for (const r of result.results) { - if (r.success) { - enabledToolsets.push(r.name); - } else { - failedToolsets.push(r.name); - console.warn( - `Failed to enable toolset '${r.name}' for client '${context.clientId}': ${r.message}` - ); - } - } - - // If ALL toolsets failed, this is likely a configuration error - if (enabledToolsets.length === 0 && failedToolsets.length > 0) { - throw new Error( - `All requested toolsets failed to enable for client '${context.clientId}'. ` + - `Requested: [${requestedToolsets.join(", ")}]. ` + - `Check that toolset names in permissions match the catalog.` - ); - } - } - - // Return bundle with resolved permissions - return { - server: bundle.server, - orchestrator: bundle.orchestrator, - allowedToolsets: enabledToolsets, - failedToolsets, - }; - }; -} diff --git a/src/permissions/permissions.types.ts b/src/permissions/permissions.types.ts new file mode 100644 index 0000000..127a273 --- /dev/null +++ b/src/permissions/permissions.types.ts @@ -0,0 +1,63 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; +import type { CustomEndpointDefinition } from "../http/http.types.js"; + +export interface PermissionAwareFastifyTransportOptions { + host?: string; + port?: number; + basePath?: string; + cors?: boolean; + logger?: boolean; + app?: import("fastify").FastifyInstance; + /** + * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. + * Allows adding REST-like endpoints with Zod validation and type inference. + * Handlers receive permission context (allowedToolsets, failedToolsets). + */ + customEndpoints?: CustomEndpointDefinition[]; +} + +/** + * Context information extracted from a client request. + * Used to identify the client and resolve their permissions. + */ +export interface ClientRequestContext { + /** + * Unique identifier for the client making the request. + * May be provided via mcp-client-id header or generated as anonymous ID. + */ + clientId: string; + + /** + * Request headers that may contain permission data. + * Used for header-based permission resolution. + */ + headers?: Record; +} + +/** + * Result of permission-aware bundle creation, including the resolved permissions. + */ +export interface PermissionAwareBundle { + /** + * The MCP server instance for this client. + */ + server: McpServer; + + /** + * The orchestrator managing toolsets for this client. + */ + orchestrator: ServerOrchestrator; + + /** + * The resolved permissions (allowed toolsets) for this client. + * Contains only the toolsets that were successfully enabled. + */ + allowedToolsets: string[]; + + /** + * Toolsets that failed to enable (e.g., invalid names). + * Empty if all requested toolsets were enabled successfully. + */ + failedToolsets: string[]; +} diff --git a/src/permissions/permissions.utils.ts b/src/permissions/permissions.utils.ts new file mode 100644 index 0000000..b2f85ed --- /dev/null +++ b/src/permissions/permissions.utils.ts @@ -0,0 +1,227 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ExposurePolicy, PermissionConfig } from "../types/index.js"; +import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; +import type { PermissionResolver } from "./PermissionResolver.js"; +import type { + ClientRequestContext, + PermissionAwareBundle, +} from "./permissions.types.js"; + +// --- Validation functions (from validatePermissionConfig.ts) --- + +/** + * Validates a permission configuration object to ensure it meets all requirements. + * Throws descriptive errors for any validation failures. + * @param config - The permission configuration to validate + */ +export function validatePermissionConfig(config: PermissionConfig): void { + validateConfigExists(config); + validateSourceField(config); + validateConfigBasedPermissions(config); + validateTypes(config); +} + +/** + * @param config - The permission configuration to validate + */ +function validateConfigExists(config: PermissionConfig): void { + if (!config || typeof config !== "object") { + throw new Error( + "Permission configuration is required for createPermissionBasedMcpServer" + ); + } +} + +/** + * @param config - The permission configuration to validate + */ +function validateSourceField(config: PermissionConfig): void { + if (!config.source) { + throw new Error('Permission source must be either "headers" or "config"'); + } + + if (config.source !== "headers" && config.source !== "config") { + throw new Error( + `Invalid permission source: "${config.source}". Must be either "headers" or "config"` + ); + } +} + +/** + * @param config - The permission configuration to validate + */ +function validateConfigBasedPermissions(config: PermissionConfig): void { + if (config.source === "config") { + if (!config.staticMap && !config.resolver) { + throw new Error( + "Config-based permissions require at least one of: staticMap or resolver function" + ); + } + } +} + +/** + * @param config - The permission configuration to validate + */ +function validateTypes(config: PermissionConfig): void { + if (config.staticMap !== undefined) { + if (typeof config.staticMap !== "object" || config.staticMap === null) { + throw new Error( + "staticMap must be an object mapping client IDs to toolset arrays" + ); + } + + // Validate that staticMap values are arrays + validateStaticMapValues(config.staticMap); + } + + if (config.resolver !== undefined) { + if (typeof config.resolver !== "function") { + throw new Error( + "resolver must be a synchronous function: (clientId: string) => string[]" + ); + } + } + + if (config.defaultPermissions !== undefined) { + if (!Array.isArray(config.defaultPermissions)) { + throw new Error("defaultPermissions must be an array of toolset names"); + } + } + + if (config.headerName !== undefined) { + if (typeof config.headerName !== "string" || config.headerName.length === 0) { + throw new Error("headerName must be a non-empty string"); + } + } +} + +/** + * @param staticMap - The static map to validate + */ +function validateStaticMapValues(staticMap: Record): void { + for (const [clientId, permissions] of Object.entries(staticMap)) { + if (!Array.isArray(permissions)) { + throw new Error( + `staticMap value for client "${clientId}" must be an array of toolset names` + ); + } + } +} + +// --- createPermissionAwareBundle (from createPermissionAwareBundle.ts) --- + +/** + * Creates a permission-aware bundle creation function that wraps the original + * createBundle function with permission resolution and enforcement. + * + * @param originalCreateBundle - Bundle creation function that accepts allowed toolsets + * @param permissionResolver - Resolver instance for determining client permissions + * @returns Enhanced bundle creation function that accepts client context + */ +export function createPermissionAwareBundle( + originalCreateBundle: (allowedToolsets: string[]) => { + server: McpServer; + orchestrator: ServerOrchestrator; + }, + permissionResolver: PermissionResolver +) { + return async ( + context: ClientRequestContext + ): Promise => { + // Resolve permissions for this client + const requestedToolsets = permissionResolver.resolvePermissions( + context.clientId, + context.headers + ); + + // Create bundle with allowed toolsets (STATIC mode pre-loads them) + const bundle = originalCreateBundle(requestedToolsets); + + // Wait for toolsets to be enabled before returning + // This ensures tools are registered before the server connects to transport + const manager = bundle.orchestrator.getManager(); + + const enabledToolsets: string[] = []; + const failedToolsets: string[] = []; + + if (requestedToolsets.length > 0) { + const result = await manager.enableToolsets(requestedToolsets); + + // Collect successful and failed toolsets + for (const r of result.results) { + if (r.success) { + enabledToolsets.push(r.name); + } else { + failedToolsets.push(r.name); + console.warn( + `Failed to enable toolset '${r.name}' for client '${context.clientId}': ${r.message}` + ); + } + } + + // If ALL toolsets failed, this is likely a configuration error + if (enabledToolsets.length === 0 && failedToolsets.length > 0) { + throw new Error( + `All requested toolsets failed to enable for client '${context.clientId}'. ` + + `Requested: [${requestedToolsets.join(", ")}]. ` + + `Check that toolset names in permissions match the catalog.` + ); + } + } + + // Return bundle with resolved permissions + return { + server: bundle.server, + orchestrator: bundle.orchestrator, + allowedToolsets: enabledToolsets, + failedToolsets, + }; + }; +} + +// --- sanitizeExposurePolicyForPermissions (from createPermissionBasedMcpServer.ts) --- + +/** + * Validates and sanitizes exposure policy for permission-based servers. + * Certain policy options are not applicable or could conflict with permission-based access control. + * @param policy - The original exposure policy + * @returns Sanitized policy safe for permission-based servers + */ +export function sanitizeExposurePolicyForPermissions( + policy?: ExposurePolicy +): ExposurePolicy | undefined { + if (!policy) return undefined; + + const sanitized: ExposurePolicy = { + namespaceToolsWithSetKey: policy.namespaceToolsWithSetKey, + }; + + // Warn about ignored options + if (policy.allowlist !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.allowlist is ignored. " + + "Allowed toolsets are determined by client permissions." + ); + } + if (policy.denylist !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.denylist is ignored. " + + "Use permission configuration to control toolset access." + ); + } + if (policy.maxActiveToolsets !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.maxActiveToolsets is ignored. " + + "Toolset count is determined by client permissions." + ); + } + if (policy.onLimitExceeded !== undefined) { + console.warn( + "Permission-based servers: exposurePolicy.onLimitExceeded is ignored. " + + "No toolset limits are enforced." + ); + } + + return sanitized; +} diff --git a/src/permissions/validatePermissionConfig.ts b/src/permissions/validatePermissionConfig.ts deleted file mode 100644 index f428887..0000000 --- a/src/permissions/validatePermissionConfig.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { PermissionConfig } from "../types/index.js"; - -/** - * Validates a permission configuration object to ensure it meets all requirements. - * Throws descriptive errors for any validation failures. - * @param config - The permission configuration to validate - * @throws {Error} If the configuration is invalid or missing required fields - */ -export function validatePermissionConfig(config: PermissionConfig): void { - validateConfigExists(config); - validateSourceField(config); - validateConfigBasedPermissions(config); - validateTypes(config); -} - -/** - * Validates that the configuration object exists. - * @param config - The permission configuration to validate - * @throws {Error} If config is null, undefined, or not an object - * @private - */ -function validateConfigExists(config: PermissionConfig): void { - if (!config || typeof config !== "object") { - throw new Error( - "Permission configuration is required for createPermissionBasedMcpServer" - ); - } -} - -/** - * Validates that the source field is present and has a valid value. - * @param config - The permission configuration to validate - * @throws {Error} If source is missing or not 'headers' or 'config' - * @private - */ -function validateSourceField(config: PermissionConfig): void { - if (!config.source) { - throw new Error('Permission source must be either "headers" or "config"'); - } - - if (config.source !== "headers" && config.source !== "config") { - throw new Error( - `Invalid permission source: "${config.source}". Must be either "headers" or "config"` - ); - } -} - -/** - * Validates config-based permission requirements. - * When source is 'config', at least one of staticMap or resolver must be provided. - * @param config - The permission configuration to validate - * @throws {Error} If config source is used but neither staticMap nor resolver is provided - * @private - */ -function validateConfigBasedPermissions(config: PermissionConfig): void { - if (config.source === "config") { - if (!config.staticMap && !config.resolver) { - throw new Error( - "Config-based permissions require at least one of: staticMap or resolver function" - ); - } - } -} - -/** - * Validates the types of configuration fields. - * Ensures staticMap is an object and resolver is a function when provided. - * @param config - The permission configuration to validate - * @throws {Error} If staticMap or resolver have incorrect types - * @private - */ -function validateTypes(config: PermissionConfig): void { - if (config.staticMap !== undefined) { - if (typeof config.staticMap !== "object" || config.staticMap === null) { - throw new Error( - "staticMap must be an object mapping client IDs to toolset arrays" - ); - } - - // Validate that staticMap values are arrays - validateStaticMapValues(config.staticMap); - } - - if (config.resolver !== undefined) { - if (typeof config.resolver !== "function") { - throw new Error( - "resolver must be a synchronous function: (clientId: string) => string[]" - ); - } - } - - if (config.defaultPermissions !== undefined) { - if (!Array.isArray(config.defaultPermissions)) { - throw new Error("defaultPermissions must be an array of toolset names"); - } - } - - if (config.headerName !== undefined) { - if (typeof config.headerName !== "string" || config.headerName.length === 0) { - throw new Error("headerName must be a non-empty string"); - } - } -} - -/** - * Validates that all values in the staticMap are arrays. - * @param staticMap - The static map to validate - * @throws {Error} If any value in the staticMap is not an array - * @private - */ -function validateStaticMapValues(staticMap: Record): void { - for (const [clientId, permissions] of Object.entries(staticMap)) { - if (!Array.isArray(permissions)) { - throw new Error( - `staticMap value for client "${clientId}" must be an array of toolset names` - ); - } - } -} diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index 8ac076e..0b43bea 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -1,66 +1,16 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - ExposurePolicy, - Mode, - ModuleLoader, - SessionContextConfig, - ToolSetCatalog, -} from "../types/index.js"; +import type { Mode } from "../types/index.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { FastifyTransport, - type FastifyTransportOptions, } from "../http/FastifyTransport.js"; import { SessionContextResolver } from "../session/SessionContextResolver.js"; -import { validateSessionContextConfig } from "../session/validateSessionContextConfig.js"; +import { validateSessionContextConfig } from "../session/session.utils.js"; import { z } from "zod"; +import type { CreateMcpServerOptions } from "./server.types.js"; +import { startupConfigSchema } from "./server.utils.js"; -export interface CreateMcpServerOptions { - catalog: ToolSetCatalog; - moduleLoaders?: Record; - exposurePolicy?: ExposurePolicy; - context?: unknown; - startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; - registerMetaTools?: boolean; - http?: FastifyTransportOptions; - /** - * Factory to create an MCP server instance. Required. - * In DYNAMIC mode, a new instance is created per client bundle. - * In STATIC mode, a single instance is created and reused across bundles. - */ - createServer: () => McpServer; - configSchema?: object; - /** - * Optional per-session context configuration. - * Enables extracting context from query parameters and merging with base context - * on a per-request basis. Useful for multi-tenant scenarios. - * - * @example - * ```typescript - * sessionContext: { - * enabled: true, - * queryParam: { - * name: 'config', - * encoding: 'base64', - * allowedKeys: ['API_TOKEN', 'USER_ID'], - * }, - * merge: 'shallow', - * } - * ``` - */ - sessionContext?: SessionContextConfig; -} - -/** - * Zod schema for validating startup configuration. - * Uses strict mode to reject unknown properties like 'initialToolsets'. - */ -const startupConfigSchema = z - .object({ - mode: z.enum(["DYNAMIC", "STATIC"]).optional(), - toolsets: z.union([z.array(z.string()), z.literal("ALL")]).optional(), - }) - .strict(); +export type { CreateMcpServerOptions } from "./server.types.js"; export async function createMcpServer(options: CreateMcpServerOptions) { // Validate startup configuration if provided @@ -85,7 +35,12 @@ export async function createMcpServer(options: CreateMcpServerOptions) { let sessionContextResolver: SessionContextResolver | undefined; if (options.sessionContext) { validateSessionContextConfig(options.sessionContext); - sessionContextResolver = new SessionContextResolver(options.sessionContext); + sessionContextResolver = SessionContextResolver.builder() + .enabled(options.sessionContext.enabled ?? true) + .queryParam(options.sessionContext.queryParam) + .contextResolver(options.sessionContext.contextResolver) + .merge(options.sessionContext.merge ?? "shallow") + .build(); // Warn if sessionContext is used with STATIC mode (limited effect) if (mode === "STATIC" && options.sessionContext.enabled !== false) { @@ -110,12 +65,6 @@ export async function createMcpServer(options: CreateMcpServerOptions) { const hasNotifierB = (s: unknown): s is NotifierB => typeof (s as NotifierB)?.notifyToolsListChanged === "function"; - /** - * Sends a tools list changed notification to the client. - * Logs warnings on failure instead of throwing. - * Suppresses "Not connected" errors as they're expected when no clients are connected. - * @param target - The MCP server instance - */ const notifyToolsChanged = async (target: unknown) => { try { if (hasNotifierA(target)) { @@ -138,19 +87,20 @@ export async function createMcpServer(options: CreateMcpServerOptions) { } }; - const orchestrator = new ServerOrchestrator({ - server: baseServer, - catalog: options.catalog, - moduleLoaders: options.moduleLoaders, - exposurePolicy: options.exposurePolicy, - context: options.context, - notifyToolsListChanged: async () => notifyToolsChanged(baseServer), - startup: options.startup, - registerMetaTools: + const orchestrator = ServerOrchestrator.builder() + .server(baseServer) + .catalog(options.catalog) + .moduleLoaders(options.moduleLoaders ?? {}) + .exposurePolicy(options.exposurePolicy!) + .context(options.context) + .notifyToolsListChanged(async () => notifyToolsChanged(baseServer)) + .startup(options.startup!) + .registerMetaTools( options.registerMetaTools !== undefined ? options.registerMetaTools - : mode === "DYNAMIC", - }); + : mode === "DYNAMIC" + ) + .build(); // In STATIC mode, wait for initialization to complete before starting if (mode === "STATIC") { @@ -171,19 +121,20 @@ export async function createMcpServer(options: CreateMcpServerOptions) { return { server: baseServer, orchestrator }; } const createdServer: McpServer = options.createServer(); - const createdOrchestrator = new ServerOrchestrator({ - server: createdServer, - catalog: options.catalog, - moduleLoaders: options.moduleLoaders, - exposurePolicy: options.exposurePolicy, - context: effectiveContext, - notifyToolsListChanged: async () => notifyToolsChanged(createdServer), - startup: options.startup, - registerMetaTools: + const createdOrchestrator = ServerOrchestrator.builder() + .server(createdServer) + .catalog(options.catalog) + .moduleLoaders(options.moduleLoaders ?? {}) + .exposurePolicy(options.exposurePolicy!) + .context(effectiveContext) + .notifyToolsListChanged(async () => notifyToolsChanged(createdServer)) + .startup(options.startup!) + .registerMetaTools( options.registerMetaTools !== undefined ? options.registerMetaTools - : mode === "DYNAMIC", - }); + : mode === "DYNAMIC" + ) + .build(); return { server: createdServer, orchestrator: createdOrchestrator }; }, options.http, diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index b618334..624d600 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -1,117 +1,17 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CreatePermissionBasedMcpServerOptions, - ExposurePolicy, } from "../types/index.js"; -import { validatePermissionConfig } from "../permissions/validatePermissionConfig.js"; -import { validateSessionContextConfig } from "../session/validateSessionContextConfig.js"; +import { + validatePermissionConfig, + createPermissionAwareBundle, + sanitizeExposurePolicyForPermissions, +} from "../permissions/permissions.utils.js"; +import { validateSessionContextConfig } from "../session/session.utils.js"; import { PermissionResolver } from "../permissions/PermissionResolver.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; -import { createPermissionAwareBundle } from "../permissions/createPermissionAwareBundle.js"; import { PermissionAwareFastifyTransport } from "../permissions/PermissionAwareFastifyTransport.js"; -/** - * Validates and sanitizes exposure policy for permission-based servers. - * Certain policy options are not applicable or could conflict with permission-based access control. - * @param policy - The original exposure policy - * @returns Sanitized policy safe for permission-based servers - * @private - */ -function sanitizeExposurePolicyForPermissions( - policy?: ExposurePolicy -): ExposurePolicy | undefined { - if (!policy) return undefined; - - const sanitized: ExposurePolicy = { - namespaceToolsWithSetKey: policy.namespaceToolsWithSetKey, - }; - - // Warn about ignored options - if (policy.allowlist !== undefined) { - console.warn( - "Permission-based servers: exposurePolicy.allowlist is ignored. " + - "Allowed toolsets are determined by client permissions." - ); - } - if (policy.denylist !== undefined) { - console.warn( - "Permission-based servers: exposurePolicy.denylist is ignored. " + - "Use permission configuration to control toolset access." - ); - } - if (policy.maxActiveToolsets !== undefined) { - console.warn( - "Permission-based servers: exposurePolicy.maxActiveToolsets is ignored. " + - "Toolset count is determined by client permissions." - ); - } - if (policy.onLimitExceeded !== undefined) { - console.warn( - "Permission-based servers: exposurePolicy.onLimitExceeded is ignored. " + - "No toolset limits are enforced." - ); - } - - return sanitized; -} - -/** - * Creates an MCP server with permission-based toolset access control. - * - * This function provides a separate API for creating servers where each client receives - * only the toolsets they're authorized to access. Each client gets a fresh server instance - * with STATIC mode configured to their allowed toolsets, ensuring per-client isolation - * without meta-tools or dynamic loading. - * - * The server supports two permission sources: - * - **Header-based**: Permissions are read from request headers (e.g., `mcp-toolset-permissions`) - * - **Config-based**: Permissions are resolved server-side using static maps or resolver functions - * - * @param options - Configuration options including permission settings, catalog, and HTTP transport options - * @returns Server instance with `server`, `start()`, and `close()` methods matching the createMcpServer interface - * @throws {Error} If permission configuration is invalid, missing, or if startup.mode is provided - * - * @example - * // Header-based permissions - * const server = await createPermissionBasedMcpServer({ - * createServer: () => new McpServer({ name: "my-server", version: "1.0.0" }), - * catalog: { toolsetA: { name: "Toolset A", tools: [...] } }, - * permissions: { - * source: 'headers', - * headerName: 'mcp-toolset-permissions' // optional, this is the default - * } - * }); - * - * @example - * // Config-based permissions with static map - * const server = await createPermissionBasedMcpServer({ - * createServer: () => new McpServer({ name: "my-server", version: "1.0.0" }), - * catalog: { toolsetA: { name: "Toolset A", tools: [...] } }, - * permissions: { - * source: 'config', - * staticMap: { - * 'client-1': ['toolsetA', 'toolsetB'], - * 'client-2': ['toolsetC'] - * }, - * defaultPermissions: [] // optional, defaults to empty array - * } - * }); - * - * @example - * // Config-based permissions with resolver function - * const server = await createPermissionBasedMcpServer({ - * createServer: () => new McpServer({ name: "my-server", version: "1.0.0" }), - * catalog: { toolsetA: { name: "Toolset A", tools: [...] } }, - * permissions: { - * source: 'config', - * resolver: (clientId) => { - * // Your custom logic to determine permissions - * return clientId.startsWith('admin-') ? ['toolsetA', 'toolsetB'] : ['toolsetA']; - * }, - * defaultPermissions: ['toolsetA'] // fallback if resolver fails - * } - * }); - */ export async function createPermissionBasedMcpServer( options: CreatePermissionBasedMcpServerOptions ) { @@ -158,53 +58,66 @@ export async function createPermissionBasedMcpServer( ); // Create permission resolver instance - const permissionResolver = new PermissionResolver(options.permissions); + const permissionResolver = PermissionResolver.builder() + .source(options.permissions.source) + .headerName(options.permissions.headerName ?? "mcp-toolset-permissions") + .staticMap(options.permissions.staticMap ?? {}) + .resolver(options.permissions.resolver as (clientId: string) => string[]) + .defaultPermissions(options.permissions.defaultPermissions ?? []) + .build(); // Create base server for default manager (used for status endpoints) const baseServer: McpServer = options.createServer(); // Create base orchestrator for default manager (empty toolsets for status endpoint) // No notifier needed - STATIC mode with fixed toolsets per client - const baseOrchestrator = new ServerOrchestrator({ - server: baseServer, - catalog: options.catalog, - moduleLoaders: options.moduleLoaders, - exposurePolicy: sanitizedPolicy, - context: options.context, - notifyToolsListChanged: undefined, // No notifications in STATIC mode - startup: { mode: "STATIC", toolsets: [] }, - registerMetaTools: false, - }); + const baseOrchestrator = ServerOrchestrator.builder() + .server(baseServer) + .catalog(options.catalog) + .moduleLoaders(options.moduleLoaders ?? {}) + .exposurePolicy(sanitizedPolicy!) + .context(options.context) + .startup({ mode: "STATIC", toolsets: [] }) + .registerMetaTools(false) + .build(); // Create permission-aware bundle creator const createBundle = createPermissionAwareBundle( (allowedToolsets: string[]) => { // Create fresh server and orchestrator for each client - // Use STATIC mode but don't auto-enable toolsets in constructor - // We'll enable them manually in createPermissionAwareBundle to ensure they're loaded before connection const clientServer: McpServer = options.createServer(); - const clientOrchestrator = new ServerOrchestrator({ - server: clientServer, - catalog: options.catalog, - moduleLoaders: options.moduleLoaders, - exposurePolicy: sanitizedPolicy, - context: options.context, - notifyToolsListChanged: undefined, // No notifications in STATIC mode - startup: { mode: "STATIC", toolsets: [] }, // Empty - we'll enable manually - registerMetaTools: false, // No meta-tools - toolsets are fixed per client - }); + const clientOrchestrator = ServerOrchestrator.builder() + .server(clientServer) + .catalog(options.catalog) + .moduleLoaders(options.moduleLoaders ?? {}) + .exposurePolicy(sanitizedPolicy!) + .context(options.context) + .startup({ mode: "STATIC", toolsets: [] }) + .registerMetaTools(false) + .build(); return { server: clientServer, orchestrator: clientOrchestrator }; }, permissionResolver ); // Create permission-aware transport - const transport = new PermissionAwareFastifyTransport( - baseOrchestrator.getManager(), - createBundle, - options.http, - options.configSchema - ); + const transportBuilder = PermissionAwareFastifyTransport.builder() + .defaultManager(baseOrchestrator.getManager()) + .createPermissionAwareBundle(createBundle) + .host(options.http?.host ?? "0.0.0.0") + .port(options.http?.port ?? 3000) + .basePath(options.http?.basePath ?? "/") + .cors(options.http?.cors ?? true) + .logger(options.http?.logger ?? false); + + if (options.http?.app) { + transportBuilder.app(options.http.app); + } + if (options.http?.customEndpoints) { + transportBuilder.customEndpoints(options.http.customEndpoints); + } + + const transport = transportBuilder.build(); // Return same interface as createMcpServer return { diff --git a/src/server/server.types.ts b/src/server/server.types.ts new file mode 100644 index 0000000..4c47d27 --- /dev/null +++ b/src/server/server.types.ts @@ -0,0 +1,45 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + ExposurePolicy, + Mode, + ModuleLoader, + SessionContextConfig, + ToolSetCatalog, +} from "../types/index.js"; +import type { FastifyTransportOptions } from "../http/http.types.js"; + +export interface CreateMcpServerOptions { + catalog: ToolSetCatalog; + moduleLoaders?: Record; + exposurePolicy?: ExposurePolicy; + context?: unknown; + startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; + registerMetaTools?: boolean; + http?: FastifyTransportOptions; + /** + * Factory to create an MCP server instance. Required. + * In DYNAMIC mode, a new instance is created per client bundle. + * In STATIC mode, a single instance is created and reused across bundles. + */ + createServer: () => McpServer; + configSchema?: object; + /** + * Optional per-session context configuration. + * Enables extracting context from query parameters and merging with base context + * on a per-request basis. Useful for multi-tenant scenarios. + * + * @example + * ```typescript + * sessionContext: { + * enabled: true, + * queryParam: { + * name: 'config', + * encoding: 'base64', + * allowedKeys: ['API_TOKEN', 'USER_ID'], + * }, + * merge: 'shallow', + * } + * ``` + */ + sessionContext?: SessionContextConfig; +} diff --git a/src/server/server.utils.ts b/src/server/server.utils.ts new file mode 100644 index 0000000..a8dad8c --- /dev/null +++ b/src/server/server.utils.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +/** + * Zod schema for validating startup configuration. + * Uses strict mode to reject unknown properties like 'initialToolsets'. + */ +export const startupConfigSchema = z + .object({ + mode: z.enum(["DYNAMIC", "STATIC"]).optional(), + toolsets: z.union([z.array(z.string()), z.literal("ALL")]).optional(), + }) + .strict(); diff --git a/src/session/ClientResourceCache.ts b/src/session/ClientResourceCache.ts index a947dc6..7f6f6ba 100644 --- a/src/session/ClientResourceCache.ts +++ b/src/session/ClientResourceCache.ts @@ -1,20 +1,4 @@ -export interface ClientResourceCacheOptions { - maxSize?: number; - ttlMs?: number; // ms - pruneIntervalMs?: number; - /** - * Optional cleanup callback called when a resource is removed from the cache. - * Use this to close connections, clean up sessions, etc. - * @param key - The cache key being removed - * @param resource - The resource being removed - */ - onEvict?: (key: string, resource: T) => void | Promise; -} - -interface Entry { - resource: T; - lastAccessed: number; -} +import type { ClientResourceCacheOptions, Entry } from "./session.types.js"; export class ClientResourceCache { private storage = new Map>(); @@ -32,6 +16,18 @@ export class ClientResourceCache { this.pruneInterval = setInterval(() => this.pruneExpired(), pruneEvery); } + static builder() { + const opts: ClientResourceCacheOptions = {}; + const builder = { + maxSize(value: number) { opts.maxSize = value; return builder; }, + ttlMs(value: number) { opts.ttlMs = value; return builder; }, + pruneIntervalMs(value: number) { opts.pruneIntervalMs = value; return builder; }, + onEvict(value: (key: string, resource: T) => void | Promise) { opts.onEvict = value; return builder; }, + build() { return new ClientResourceCache(opts); }, + }; + return builder; + } + public getEntryCount(): number { return this.storage.size; } @@ -66,8 +62,6 @@ export class ClientResourceCache { } /** - * Removes an entry from the cache. - * Calls the onEvict callback if configured. * @param key - The key to remove */ public delete(key: string): void { @@ -79,7 +73,6 @@ export class ClientResourceCache { } /** - * Stops the background pruning interval and optionally clears all entries. * @param clearEntries - If true, also removes all entries and calls onEvict for each */ public stop(clearEntries = false): void { @@ -92,10 +85,6 @@ export class ClientResourceCache { } } - /** - * Clears all entries from the cache. - * Calls onEvict for each entry being removed. - */ public clear(): void { // Collect all entries first to avoid modification during iteration const entries = Array.from(this.storage.entries()); @@ -105,10 +94,6 @@ export class ClientResourceCache { } } - /** - * Evicts the least recently used entry from the cache. - * @private - */ private evictLeastRecentlyUsed(): void { const lruKey = this.storage.keys().next().value as string | undefined; if (lruKey) { @@ -116,10 +101,6 @@ export class ClientResourceCache { } } - /** - * Removes all expired entries from the cache. - * @private - */ private pruneExpired(): void { const now = Date.now(); const keysToDelete: string[] = []; @@ -135,10 +116,8 @@ export class ClientResourceCache { } /** - * Safely calls the evict callback, catching and logging any errors. * @param key - The key being evicted * @param resource - The resource being evicted - * @private */ #callEvictCallback(key: string, resource: T): void { if (!this.onEvict) return; diff --git a/src/session/SessionContextResolver.ts b/src/session/SessionContextResolver.ts index aaf9270..cb9baf2 100644 --- a/src/session/SessionContextResolver.ts +++ b/src/session/SessionContextResolver.ts @@ -3,60 +3,8 @@ import type { SessionRequestContext, } from "../types/index.js"; import { createHash } from "node:crypto"; +import type { SessionContextResult } from "./session.types.js"; -/** - * Result of session context resolution including the merged context - * and a cache key suffix for differentiating sessions with different configs. - */ -export interface SessionContextResult { - /** - * The merged context to pass to module loaders. - */ - context: unknown; - - /** - * A deterministic hash suffix based on the session config values. - * Used to differentiate cache entries: `${clientId}:${cacheKeySuffix}` - * Returns 'default' when no session config is present. - */ - cacheKeySuffix: string; -} - -/** - * Resolves per-session context from request query parameters and merges - * it with the base server context. - * - * Features: - * - Parses query parameter (base64 or JSON encoded) - * - Filters allowed keys (whitelist enforcement) - * - Merges session context with base context (shallow or deep) - * - Generates cache key suffix for session differentiation - * - * Security considerations: - * - Always specify allowedKeys to whitelist permitted session config keys - * - Invalid encoding silently returns empty session config (fail secure) - * - Disallowed keys are filtered without logging (prevents info leakage) - * - * @example - * ```typescript - * const resolver = new SessionContextResolver({ - * enabled: true, - * queryParam: { - * name: 'config', - * encoding: 'base64', - * allowedKeys: ['API_TOKEN', 'USER_ID'], - * }, - * merge: 'shallow', - * }); - * - * const result = resolver.resolve( - * { clientId: 'client-1', headers: {}, query: { config: 'eyJBUElfVE9LRU4iOiJ0b2tlbiJ9' } }, - * { baseValue: 'foo' } - * ); - * // result.context = { baseValue: 'foo', API_TOKEN: 'token' } - * // result.cacheKeySuffix = 'abc123...' - * ``` - */ export class SessionContextResolver { private readonly config: SessionContextConfig; private readonly queryParamName: string; @@ -74,9 +22,19 @@ export class SessionContextResolver { this.mergeStrategy = config.merge ?? "shallow"; } + static builder() { + const opts: Partial = {}; + const builder = { + enabled(value: boolean) { opts.enabled = value; return builder; }, + queryParam(value: SessionContextConfig["queryParam"]) { opts.queryParam = value; return builder; }, + contextResolver(value: SessionContextConfig["contextResolver"]) { opts.contextResolver = value; return builder; }, + merge(value: "shallow" | "deep") { opts.merge = value; return builder; }, + build() { return new SessionContextResolver(opts as SessionContextConfig); }, + }; + return builder; + } + /** - * Resolves the session context for a request. - * * @param request - The request context (clientId, headers, query) * @param baseContext - The base context from server configuration * @returns The resolved context and cache key suffix @@ -126,12 +84,8 @@ export class SessionContextResolver { } /** - * Parses the session config from query parameters. - * Returns empty object on parse failure (fail secure). - * * @param query - Query parameters from the request * @returns Parsed and filtered config object - * @private */ private parseQueryConfig( query: Record @@ -168,12 +122,8 @@ export class SessionContextResolver { } /** - * Filters the parsed config to only include allowed keys. - * If no allowedKeys whitelist is configured, returns the full object. - * * @param parsed - The parsed config object * @returns Filtered config with only allowed keys - * @private */ private filterAllowedKeys( parsed: Record @@ -192,12 +142,9 @@ export class SessionContextResolver { } /** - * Merges the base context with the session config. - * * @param baseContext - The base context from server configuration * @param sessionConfig - The parsed session config * @returns Merged context - * @private */ private mergeContexts( baseContext: unknown, @@ -232,13 +179,9 @@ export class SessionContextResolver { } /** - * Performs a deep merge of two objects. - * Session config values override base context values. - * * @param base - The base object * @param override - The override object * @returns Deep merged object - * @private */ private deepMerge( base: Record, @@ -272,12 +215,8 @@ export class SessionContextResolver { } /** - * Generates a deterministic cache key suffix based on the session config. - * Returns 'default' when no session config is present. - * * @param sessionConfig - The parsed session config * @returns Hash string or 'default' - * @private */ private generateCacheKeySuffix( sessionConfig: Record diff --git a/src/session/session.types.ts b/src/session/session.types.ts new file mode 100644 index 0000000..c70c355 --- /dev/null +++ b/src/session/session.types.ts @@ -0,0 +1,31 @@ +export interface SessionContextResult { + /** + * The merged context to pass to module loaders. + */ + context: unknown; + + /** + * A deterministic hash suffix based on the session config values. + * Used to differentiate cache entries: `${clientId}:${cacheKeySuffix}` + * Returns 'default' when no session config is present. + */ + cacheKeySuffix: string; +} + +export interface ClientResourceCacheOptions { + maxSize?: number; + ttlMs?: number; + pruneIntervalMs?: number; + /** + * Optional cleanup callback called when a resource is removed from the cache. + * Use this to close connections, clean up sessions, etc. + * @param key - The cache key being removed + * @param resource - The resource being removed + */ + onEvict?: (key: string, resource: T) => void | Promise; +} + +export interface Entry { + resource: T; + lastAccessed: number; +} diff --git a/src/session/validateSessionContextConfig.ts b/src/session/session.utils.ts similarity index 82% rename from src/session/validateSessionContextConfig.ts rename to src/session/session.utils.ts index 357de05..3f40fdf 100644 --- a/src/session/validateSessionContextConfig.ts +++ b/src/session/session.utils.ts @@ -5,7 +5,6 @@ import type { SessionContextConfig } from "../types/index.js"; * Throws descriptive errors for any validation failures. * * @param config - The session context configuration to validate - * @throws {Error} If the configuration is invalid or has incorrect types */ export function validateSessionContextConfig(config: SessionContextConfig): void { validateConfigExists(config); @@ -16,11 +15,7 @@ export function validateSessionContextConfig(config: SessionContextConfig): void } /** - * Validates that the configuration object exists and is an object. - * * @param config - The session context configuration to validate - * @throws {Error} If config is null, undefined, or not an object - * @private */ function validateConfigExists(config: SessionContextConfig): void { if (!config || typeof config !== "object") { @@ -31,11 +26,7 @@ function validateConfigExists(config: SessionContextConfig): void { } /** - * Validates the enabled field if provided. - * * @param config - The session context configuration to validate - * @throws {Error} If enabled is not a boolean - * @private */ function validateEnabledField(config: SessionContextConfig): void { if (config.enabled === undefined) { @@ -50,11 +41,7 @@ function validateEnabledField(config: SessionContextConfig): void { } /** - * Validates the queryParam configuration if provided. - * * @param config - The session context configuration to validate - * @throws {Error} If queryParam fields have invalid types or values - * @private */ function validateQueryParamConfig(config: SessionContextConfig): void { if (config.queryParam === undefined) { @@ -105,11 +92,7 @@ function validateQueryParamConfig(config: SessionContextConfig): void { } /** - * Validates the contextResolver if provided. - * * @param config - The session context configuration to validate - * @throws {Error} If contextResolver is not a function - * @private */ function validateContextResolver(config: SessionContextConfig): void { if (config.contextResolver === undefined) { @@ -124,11 +107,7 @@ function validateContextResolver(config: SessionContextConfig): void { } /** - * Validates the merge strategy if provided. - * * @param config - The session context configuration to validate - * @throws {Error} If merge is not 'shallow' or 'deep' - * @private */ function validateMergeStrategy(config: SessionContextConfig): void { if (config.merge === undefined) { diff --git a/src/types/index.ts b/src/types/index.ts index f2fb827..69cf0be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import type { CreateMcpServerOptions } from "../server/createMcpServer.js"; +import type { CreateMcpServerOptions } from "../server/server.types.js"; // Loader concepts are internal-only; no public types for loaders diff --git a/tests/createPermissionAwareBundle.test.ts b/tests/createPermissionAwareBundle.test.ts index e70b280..9e8df84 100644 --- a/tests/createPermissionAwareBundle.test.ts +++ b/tests/createPermissionAwareBundle.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { - createPermissionAwareBundle, - type ClientRequestContext, -} from "../src/permissions/createPermissionAwareBundle.js"; +import { createPermissionAwareBundle } from "../src/permissions/permissions.utils.js"; +import type { ClientRequestContext } from "../src/permissions/permissions.types.js"; import { PermissionResolver } from "../src/permissions/PermissionResolver.js"; import type { PermissionConfig } from "../src/types/index.js"; import { createFakeMcpServer } from "./helpers/fakes.js"; diff --git a/tests/customEndpoints.test.ts b/tests/customEndpoints.test.ts index 06388e6..cb9e98b 100644 --- a/tests/customEndpoints.test.ts +++ b/tests/customEndpoints.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { z } from "zod"; import Fastify, { type FastifyInstance } from "fastify"; -import { registerCustomEndpoints } from "../src/http/endpointRegistration.js"; -import { defineEndpoint, definePermissionAwareEndpoint } from "../src/http/customEndpoints.js"; +import { registerCustomEndpoints, defineEndpoint, definePermissionAwareEndpoint } from "../src/http/http.utils.js"; describe("Custom Endpoints", () => { let app: FastifyInstance; diff --git a/tests/permissionAwareFastifyTransport.test.ts b/tests/permissionAwareFastifyTransport.test.ts index 6ee1cef..3f63daa 100644 --- a/tests/permissionAwareFastifyTransport.test.ts +++ b/tests/permissionAwareFastifyTransport.test.ts @@ -4,7 +4,7 @@ import type { DynamicToolManager } from "../src/core/DynamicToolManager.js"; import type { ClientRequestContext, PermissionAwareBundle, -} from "../src/permissions/createPermissionAwareBundle.js"; +} from "../src/permissions/permissions.types.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import Fastify, { type FastifyInstance } from "fastify"; diff --git a/tests/validatePermissionConfig.test.ts b/tests/validatePermissionConfig.test.ts index 314180f..a8446c6 100644 --- a/tests/validatePermissionConfig.test.ts +++ b/tests/validatePermissionConfig.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { validatePermissionConfig } from "../src/permissions/validatePermissionConfig.js"; +import { validatePermissionConfig } from "../src/permissions/permissions.utils.js"; import type { PermissionConfig } from "../src/types/index.js"; describe("validatePermissionConfig", () => { diff --git a/tests/validateSessionContextConfig.test.ts b/tests/validateSessionContextConfig.test.ts index 353db35..041cd9b 100644 --- a/tests/validateSessionContextConfig.test.ts +++ b/tests/validateSessionContextConfig.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { validateSessionContextConfig } from "../src/session/validateSessionContextConfig.js"; +import { validateSessionContextConfig } from "../src/session/session.utils.js"; import type { SessionContextConfig } from "../src/types/index.js"; describe("validateSessionContextConfig", () => { From 0ca4a1bda9199a6e0dfc15c2cc26e7a012bf41c3 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 17:51:32 +0200 Subject: [PATCH 05/11] chore: classes cleanup --- src/core/DynamicToolManager.ts | 132 +++++++++++++++++--------- src/core/ServerOrchestrator.ts | 94 +++++++++++------- src/http/FastifyTransport.ts | 82 ++++++++++++---- src/mode/ModeResolver.ts | 36 ++++--- src/mode/ModuleResolver.ts | 63 ++++++++---- src/session/SessionContextResolver.ts | 63 ++++++++---- 6 files changed, 322 insertions(+), 148 deletions(-) diff --git a/src/core/DynamicToolManager.ts b/src/core/DynamicToolManager.ts index 33c4955..b840ac9 100644 --- a/src/core/DynamicToolManager.ts +++ b/src/core/DynamicToolManager.ts @@ -81,20 +81,10 @@ export class DynamicToolManager { toolsetName: string, skipNotification = false ): Promise<{ success: boolean; message: string }> { - const validation = this.resolver.validateToolsetName(toolsetName); - if (!validation.isValid || !validation.sanitized) { - return { - success: false, - message: validation.error || "Unknown validation error", - }; - } - const sanitized = validation.sanitized; - if (this.activeToolsets.has(sanitized)) { - return { - success: false, - message: `Toolset '${sanitized}' is already enabled.`, - }; - } + const earlyExit = this.validateToolsetForEnable(toolsetName); + if (earlyExit) return earlyExit; + + const sanitized = this.resolver.validateToolsetName(toolsetName).sanitized!; // Check exposure policies BEFORE resolving tools to fail fast const policyCheck = this.checkExposurePolicy(sanitized); @@ -106,22 +96,7 @@ export class DynamicToolManager { const registeredTools: string[] = []; try { - const resolvedTools = await this.resolver.resolveToolsForToolsets( - [sanitized], - this.context - ); - - // Register all resolved tools (direct + module-derived) - if (resolvedTools && resolvedTools.length > 0) { - const mapped = this.toolRegistry.mapAndValidate( - sanitized, - resolvedTools - ); - for (const tool of mapped) { - this.registerSingleTool(tool, sanitized); - registeredTools.push(tool.name); - } - } + const toolCount = await this.resolveAndRegisterTools(sanitized, registeredTools); // Track state only after successful registration this.activeToolsets.add(sanitized); @@ -131,22 +106,9 @@ export class DynamicToolManager { await this.notifyToolsChanged(); } - return { - success: true, - message: `Toolset '${sanitized}' enabled successfully. Registered ${ - resolvedTools?.length ?? 0 - } tools.`, - }; + return this.buildEnableResult(sanitized, toolCount); } catch (error) { - // Note: We cannot unregister tools from MCP server, but we can track the inconsistency - if (registeredTools.length > 0) { - console.warn( - `Partial failure enabling toolset '${sanitized}'. ` + - `${registeredTools.length} tools were registered but toolset activation failed. ` + - `Tools remain registered due to MCP limitations: ${registeredTools.join(", ")}` - ); - } - // Don't add to activeToolsets since we failed + this.handlePartialFailure(sanitized, registeredTools); return { success: false, message: `Failed to enable toolset '${sanitized}': ${ @@ -156,6 +118,86 @@ export class DynamicToolManager { } } + /** + * @param toolsetName - The raw toolset name to validate + * @returns Early exit result if invalid, or null to continue + */ + private validateToolsetForEnable( + toolsetName: string + ): { success: boolean; message: string } | null { + const validation = this.resolver.validateToolsetName(toolsetName); + if (!validation.isValid || !validation.sanitized) { + return { + success: false, + message: validation.error || "Unknown validation error", + }; + } + if (this.activeToolsets.has(validation.sanitized)) { + return { + success: false, + message: `Toolset '${validation.sanitized}' is already enabled.`, + }; + } + return null; + } + + /** + * @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 { + const resolvedTools = await this.resolver.resolveToolsForToolsets( + [sanitized], + this.context + ); + + if (resolvedTools && resolvedTools.length > 0) { + const mapped = this.toolRegistry.mapAndValidate(sanitized, resolvedTools); + for (const tool of mapped) { + this.registerSingleTool(tool, sanitized); + registeredTools.push(tool.name); + } + } + + return resolvedTools?.length ?? 0; + } + + /** + * @param sanitized - The toolset name + * @param toolCount - Number of tools registered + * @returns Success result object + */ + private buildEnableResult( + sanitized: string, + toolCount: number + ): { success: boolean; message: string } { + return { + success: true, + message: `Toolset '${sanitized}' enabled successfully. Registered ${toolCount} tools.`, + }; + } + + /** + * @param sanitized - The toolset name that partially failed + * @param registeredTools - Tools that were registered before the failure + */ + 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(", ")}` + ); + } + } + /** * @param toolsetName - The sanitized toolset name to check * @returns Object indicating if allowed and reason message if not diff --git a/src/core/ServerOrchestrator.ts b/src/core/ServerOrchestrator.ts index 086736e..446f0c4 100644 --- a/src/core/ServerOrchestrator.ts +++ b/src/core/ServerOrchestrator.ts @@ -105,43 +105,54 @@ export class ServerOrchestrator { startup: { mode?: Exclude; toolsets?: string[] | "ALL" }, catalog: ToolSetCatalog ): { mode: Exclude; toolsets?: string[] | "ALL" } { - // Explicit mode dominates if (startup.mode) { - if (startup.mode === "DYNAMIC" && startup.toolsets) { - console.warn("startup.toolsets provided but ignored in DYNAMIC mode"); - return { mode: "DYNAMIC" }; - } - if (startup.mode === "STATIC") { - if (startup.toolsets === "ALL") - return { mode: "STATIC", toolsets: "ALL" }; - const names = Array.isArray(startup.toolsets) ? startup.toolsets : []; - const valid: string[] = []; - for (const name of names) { - const { isValid, sanitized, error } = - this.toolsetValidator.validateToolsetName(name, catalog); - if (isValid && sanitized) valid.push(sanitized); - else if (error) console.warn(error); - } - if (names.length > 0 && valid.length === 0) { - throw new Error( - "STATIC mode requires valid toolsets or 'ALL'; none were valid" - ); - } - return { mode: "STATIC", toolsets: valid }; + return this.resolveExplicitMode(startup.mode, startup.toolsets, catalog); + } + return this.inferModeFromToolsets(startup, catalog); + } + + /** + * @param mode - The explicit mode + * @param toolsets - Optional toolsets from startup config + * @param catalog - The toolset catalog to validate against + * @returns Resolved mode and toolsets + */ + private resolveExplicitMode( + mode: Exclude, + toolsets: string[] | "ALL" | undefined, + catalog: ToolSetCatalog + ): { mode: Exclude; toolsets?: string[] | "ALL" } { + if (mode === "DYNAMIC" && toolsets) { + console.warn("startup.toolsets provided but ignored in DYNAMIC mode"); + return { mode: "DYNAMIC" }; + } + if (mode === "STATIC") { + if (toolsets === "ALL") + return { mode: "STATIC", toolsets: "ALL" }; + const names = Array.isArray(toolsets) ? toolsets : []; + const valid = this.validateAndCollectToolsets(names, catalog); + if (names.length > 0 && valid.length === 0) { + throw new Error( + "STATIC mode requires valid toolsets or 'ALL'; none were valid" + ); } - return { mode: startup.mode }; + return { mode: "STATIC", toolsets: valid }; } + return { mode }; + } - // No explicit mode; infer from toolsets + /** + * @param startup - Startup config without an explicit mode + * @param catalog - The toolset catalog to validate against + * @returns Inferred mode and toolsets + */ + private inferModeFromToolsets( + startup: { toolsets?: string[] | "ALL" }, + catalog: ToolSetCatalog + ): { mode: Exclude; toolsets?: string[] | "ALL" } { if (startup.toolsets === "ALL") return { mode: "STATIC", toolsets: "ALL" }; if (Array.isArray(startup.toolsets) && startup.toolsets.length > 0) { - const valid: string[] = []; - for (const name of startup.toolsets) { - const { isValid, sanitized, error } = - this.toolsetValidator.validateToolsetName(name, catalog); - if (isValid && sanitized) valid.push(sanitized); - else if (error) console.warn(error); - } + const valid = this.validateAndCollectToolsets(startup.toolsets, catalog); if (valid.length === 0) { throw new Error( "STATIC mode requires valid toolsets or 'ALL'; none were valid" @@ -149,11 +160,28 @@ export class ServerOrchestrator { } return { mode: "STATIC", toolsets: valid }; } - - // Default return { mode: "DYNAMIC" }; } + /** + * @param names - Array of toolset names to validate + * @param catalog - The toolset catalog to validate against + * @returns Array of valid, sanitized toolset names + */ + private validateAndCollectToolsets( + names: string[], + catalog: ToolSetCatalog + ): string[] { + const valid: string[] = []; + for (const name of names) { + const { isValid, sanitized, error } = + this.toolsetValidator.validateToolsetName(name, catalog); + if (isValid && sanitized) valid.push(sanitized); + else if (error) console.warn(error); + } + return valid; + } + public getMode(): Exclude { return this.mode; } diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index da1a4b5..02902e4 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -111,15 +111,56 @@ export class FastifyTransport { await app.register(cors, { origin: true }); } - const base = this.options.basePath.endsWith("/") - ? this.options.basePath.slice(0, -1) - : this.options.basePath; + const base = this.normalizeBasePath(this.options.basePath); + this.registerHealthEndpoint(app, base); + this.registerToolsEndpoint(app, base); + this.registerConfigDiscoveryEndpoint(app, base); + this.registerMcpPostEndpoint(app, base); + this.registerMcpGetEndpoint(app, base); + this.registerMcpDeleteEndpoint(app, base); + + // Register custom endpoints if provided + if (this.options.customEndpoints && this.options.customEndpoints.length > 0) { + registerCustomEndpoints(app, base, this.options.customEndpoints); + } + + // Only listen if we created the app + if (!this.options.app) { + await app.listen({ host: this.options.host, port: this.options.port }); + } + this.app = app; + } + + /** + * @param basePath - The base path to normalize + * @returns Normalized base path without trailing slash + */ + private normalizeBasePath(basePath: string): string { + return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + } + + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerHealthEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/healthz`, async () => ({ ok: true })); + } + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerToolsEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/tools`, async () => this.defaultManager.getStatus()); + } - // Config discovery (placeholder schema) + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerConfigDiscoveryEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/.well-known/mcp-config`, async (_req, reply) => { reply.header("Content-Type", "application/schema+json; charset=utf-8"); const baseSchema = this.configSchema ?? { @@ -134,8 +175,13 @@ export class FastifyTransport { }; return baseSchema; }); + } - // POST /mcp - JSON-RPC + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerMcpPostEndpoint(app: FastifyInstance, base: string): void { app.post( `${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { @@ -215,8 +261,13 @@ export class FastifyTransport { return reply; } ); + } - // GET /mcp - SSE notifications + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerMcpGetEndpoint(app: FastifyInstance, base: string): void { app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { const clientIdHeader = ( req.headers["mcp-client-id"] as string | undefined @@ -245,8 +296,13 @@ export class FastifyTransport { await transport.handleRequest((req as any).raw, (reply as any).raw); return reply; }); + } - // DELETE /mcp - terminate session + /** + * @param app - Fastify instance + * @param base - Base path for routes + */ + private registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void { app.delete( `${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { @@ -292,18 +348,6 @@ export class FastifyTransport { return reply; } ); - - // Register custom endpoints if provided - // IMPORTANT: Only register if customEndpoints is provided AND has items - if (this.options.customEndpoints && this.options.customEndpoints.length > 0) { - registerCustomEndpoints(app, base, this.options.customEndpoints); - } - - // Only listen if we created the app - if (!this.options.app) { - await app.listen({ host: this.options.host, port: this.options.port }); - } - this.app = app; } public async stop(): Promise { diff --git a/src/mode/ModeResolver.ts b/src/mode/ModeResolver.ts index 6966e61..2f20a20 100644 --- a/src/mode/ModeResolver.ts +++ b/src/mode/ModeResolver.ts @@ -82,21 +82,11 @@ export class ToolsetValidator { catalog: ToolSetCatalog ): { isValid: boolean; sanitized?: string; error?: string } { if (!name || typeof name !== "string") { - return { - isValid: false, - error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${Object.keys( - catalog - ).join(", ")}`, - }; + return this.createInvalidNameError(name, catalog); } const sanitized = name.trim(); if (sanitized.length === 0) { - return { - isValid: false, - error: `Empty toolset name provided. Available toolsets: ${Object.keys( - catalog - ).join(", ")}`, - }; + return this.createInvalidNameError(sanitized, catalog); } if (!catalog[sanitized]) { return { @@ -109,6 +99,28 @@ export class ToolsetValidator { return { isValid: true, sanitized }; } + /** + * @param name - The invalid name value + * @param catalog - The toolset catalog for listing available options + * @returns Validation result with descriptive error message + */ + private createInvalidNameError( + name: unknown, + catalog: ToolSetCatalog + ): { isValid: false; error: string } { + const available = Object.keys(catalog).join(", "); + if (!name || typeof name !== "string") { + return { + isValid: false, + error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${available}`, + }; + } + return { + isValid: false, + error: `Empty toolset name provided. Available toolsets: ${available}`, + }; + } + /** * @param toolsetNames - Array of toolset names to validate * @param catalog - The toolset catalog to validate against diff --git a/src/mode/ModuleResolver.ts b/src/mode/ModuleResolver.ts index 2360f86..4dcd92b 100644 --- a/src/mode/ModuleResolver.ts +++ b/src/mode/ModuleResolver.ts @@ -90,27 +90,52 @@ export class ModuleResolver { for (const name of toolsets) { const def = this.catalog[name]; if (!def) continue; - if (Array.isArray(def.tools) && def.tools.length > 0) { - collected.push(...def.tools); - } - if (Array.isArray(def.modules) && def.modules.length > 0) { - for (const modKey of def.modules) { - const loader = this.moduleLoaders[modKey]; - if (!loader) continue; - try { - const loaded = await loader(context); - if (Array.isArray(loaded) && loaded.length > 0) { - collected.push(...loaded); - } - } catch (err) { - console.warn( - `Module loader '${modKey}' failed for toolset '${name}':`, - err - ); - } + this.collectDirectTools(def, collected); + await this.loadModuleTools(def, name, context, collected); + } + return collected; + } + + /** + * @param def - The toolset definition + * @param collected - Mutable array to append direct tools to + */ + private collectDirectTools( + def: ToolSetDefinition, + collected: McpToolDefinition[] + ): void { + if (Array.isArray(def.tools) && def.tools.length > 0) { + collected.push(...def.tools); + } + } + + /** + * @param def - The toolset definition containing module keys + * @param toolsetName - The toolset name for error messages + * @param context - Optional context passed to module loaders + * @param collected - Mutable array to append loaded tools to + */ + private async loadModuleTools( + def: ToolSetDefinition, + toolsetName: string, + context: unknown, + collected: McpToolDefinition[] + ): Promise { + if (!Array.isArray(def.modules) || def.modules.length === 0) return; + for (const modKey of def.modules) { + const loader = this.moduleLoaders[modKey]; + if (!loader) continue; + try { + const loaded = await loader(context); + if (Array.isArray(loaded) && loaded.length > 0) { + collected.push(...loaded); } + } catch (err) { + console.warn( + `Module loader '${modKey}' failed for toolset '${toolsetName}':`, + err + ); } } - return collected; } } diff --git a/src/session/SessionContextResolver.ts b/src/session/SessionContextResolver.ts index cb9baf2..de4f630 100644 --- a/src/session/SessionContextResolver.ts +++ b/src/session/SessionContextResolver.ts @@ -51,31 +51,54 @@ export class SessionContextResolver { }; } - // Parse and filter the query parameter config const parsedConfig = this.parseQueryConfig(request.query); - // If custom resolver is provided, use it if (this.config.contextResolver) { - try { - const resolvedContext = this.config.contextResolver( - request, - baseContext, - parsedConfig - ); - return { - context: resolvedContext, - cacheKeySuffix: this.generateCacheKeySuffix(parsedConfig), - }; - } catch { - // Fail secure: return base context on resolver error - return { - context: baseContext, - cacheKeySuffix: "default", - }; - } + return this.resolveWithCustomResolver(request, baseContext, parsedConfig); + } + + return this.resolveWithDefaultMerge(baseContext, parsedConfig); + } + + /** + * @param request - The request context + * @param baseContext - The base context from server configuration + * @param parsedConfig - The parsed query parameter config + * @returns The resolved context and cache key suffix + */ + private resolveWithCustomResolver( + request: SessionRequestContext, + baseContext: unknown, + parsedConfig: Record + ): SessionContextResult { + try { + const resolvedContext = this.config.contextResolver!( + request, + baseContext, + parsedConfig + ); + return { + context: resolvedContext, + cacheKeySuffix: this.generateCacheKeySuffix(parsedConfig), + }; + } catch { + // Fail secure: return base context on resolver error + return { + context: baseContext, + cacheKeySuffix: "default", + }; } + } - // Default merge behavior + /** + * @param baseContext - The base context from server configuration + * @param parsedConfig - The parsed query parameter config + * @returns The merged context and cache key suffix + */ + private resolveWithDefaultMerge( + baseContext: unknown, + parsedConfig: Record + ): SessionContextResult { const mergedContext = this.mergeContexts(baseContext, parsedConfig); return { context: mergedContext, From 2bdf16736cddf2a2f81188efcdd889b654b9e050 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 18:10:14 +0200 Subject: [PATCH 06/11] chore: code style cleanup --- AGENTS.md | 9 + src/index.ts | 2 +- src/server/AGENTS.md | 15 +- src/server/createMcpServer.ts | 326 +++++++++++-------- src/server/createPermissionBasedMcpServer.ts | 187 +++++++---- src/server/server.types.ts | 7 + src/server/server.utils.ts | 81 +++++ tests/createMcpServer.test.ts | 24 ++ 8 files changed, 452 insertions(+), 199 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e9f34ed..0e2256d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,15 @@ Client → HTTP (Fastify) → Per-client MCP Server → ServerOrchestrator `createPermissionBasedMcpServer` — per-client STATIC servers with access control. Permissions resolved from headers or server config. No meta-tools. +## Code Style + +1. **No non-null assertions (`!`)** — If a value might be `undefined`, guard before use or use conditional builder calls. Never use `!` to silence the compiler. +2. **No `as any` in production code** — Permitted only for defensive runtime guards against misconfiguration (e.g. checking a property that doesn't exist on the type) and for SDK boundary mismatches where types are unavailable. Every `as any` should have a comment justifying it. +3. **Named functions over anonymous callbacks** — Extract inline closures longer than ~5 lines into named functions with JSDoc. Helps readability and stack traces. +4. **Builder pattern: conditional calls for optional fields** — When a builder method requires a non-optional param but the source value may be `undefined`, call the method conditionally (`if (value) { builder.method(value); }`) rather than asserting. +5. **Prefer `builder()` over raw constructors** — When a class exposes a builder, use it. Keeps construction style consistent across the codebase. +6. **Named intermediate variables** — Assign computed values (ternaries, function results) to descriptively-named `const` variables before passing them onward. + ## Critical Invariants 1. **All tools → ToolRegistry** — Collision detection happens here only diff --git a/src/index.ts b/src/index.ts index 8c341f1..e90a225 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ // Standard MCP server creation export { createMcpServer } from "./server/createMcpServer.js"; -export type { CreateMcpServerOptions } from "./server/server.types.js"; +export type { CreateMcpServerOptions, McpServerHandle } from "./server/server.types.js"; // Permission-based MCP server creation (separate API for per-client toolset access control) export { createPermissionBasedMcpServer } from "./server/createPermissionBasedMcpServer.js"; diff --git a/src/server/AGENTS.md b/src/server/AGENTS.md index 8c1cb1b..9bf6267 100644 --- a/src/server/AGENTS.md +++ b/src/server/AGENTS.md @@ -8,13 +8,24 @@ Factory functions for creating MCP servers. Provides standard and permission-bas **createMcpServer** (`createMcpServer.ts`) - Standard server factory supporting DYNAMIC or STATIC modes -- Returns `{server, start(), close()}` +- Returns `McpServerHandle` (`{server, start(), close()}`) - Supports session context for multi-tenancy +- Main flow delegates to named helpers: `validateOptions`, `buildSessionContextResolver`, `buildOrchestrator`, `createBundleFactory`, `buildTransport` **createPermissionBasedMcpServer** (`createPermissionBasedMcpServer.ts`) - Permission-controlled server factory - Always STATIC mode per client - No meta-tools (clients can't change toolsets) +- Main flow delegates to named helpers: `validatePermissionOptions`, `buildPermissionResolver`, `buildPermissionOrchestrator`, `createClientOrchestratorFactory`, `buildPermissionTransport` + +**Shared types** (`server.types.ts`) +- `CreateMcpServerOptions` — options for standard server +- `McpServerHandle` — return type for both factories (`{server, start, close}`) + +**Shared utilities** (`server.utils.ts`) +- `validateStartupConfig(startup)` — Zod parse + error formatting +- `createToolsChangedNotifier()` — encapsulated notifier pattern (type guards as closure) +- `resolveMetaToolsFlag(explicit, mode)` — meta-tools default: on for DYNAMIC, off for STATIC ## createMcpServer Options @@ -57,6 +68,7 @@ Factory functions for creating MCP servers. Provides standard and permission-bas 3. **Permission-based servers ignore startup field** - Throws if `startup` provided 4. **Permission-based sanitizes exposure policy** - Strips allowlist/denylist/maxActiveToolsets with warnings 5. **Orchestrator.ensureReady() before start** - STATIC mode waits for initialization +6. **Orchestrator built via conditional builder calls** - Optional fields (`exposurePolicy`, `startup`) are only passed to the builder when defined; never use `!` to coerce them ## Mode Selection Logic @@ -91,6 +103,7 @@ Always STATIC per client, no meta-tools - Providing `startup` to permission-based server (throws) - Expecting allowlist/denylist to work with permission-based (ignored) - Not awaiting `start()` before accepting requests +- Using non-null assertions (`!`) on optional builder params — use conditional calls instead ## Dependencies diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index 0b43bea..8a191d5 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -1,155 +1,221 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Mode } from "../types/index.js"; +import type { CreateBundleCallback } from "../http/http.types.js"; +import type { CreateMcpServerOptions, McpServerHandle } from "./server.types.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; -import { - FastifyTransport, -} from "../http/FastifyTransport.js"; +import { FastifyTransport } from "../http/FastifyTransport.js"; import { SessionContextResolver } from "../session/SessionContextResolver.js"; import { validateSessionContextConfig } from "../session/session.utils.js"; -import { z } from "zod"; -import type { CreateMcpServerOptions } from "./server.types.js"; -import { startupConfigSchema } from "./server.utils.js"; +import { + validateStartupConfig, + createToolsChangedNotifier, + resolveMetaToolsFlag, +} from "./server.utils.js"; export type { CreateMcpServerOptions } from "./server.types.js"; -export async function createMcpServer(options: CreateMcpServerOptions) { - // Validate startup configuration if provided - if (options.startup) { - try { - startupConfigSchema.parse(options.startup); - } catch (error) { - if (error instanceof z.ZodError) { - const formatted = error.format(); - throw new Error( - `Invalid startup configuration:\n${JSON.stringify(formatted, null, 2)}\n\n` + - `Hint: Common mistake - use "toolsets" not "initialToolsets"` - ); - } - throw error; - } - } +export async function createMcpServer( + options: CreateMcpServerOptions +): Promise { + // --- Validate --- + validateOptions(options); const mode: Exclude = options.startup?.mode ?? "DYNAMIC"; + const shouldRegisterMetaTools = resolveMetaToolsFlag(options.registerMetaTools, mode); + const sessionContextResolver = buildSessionContextResolver(options, mode); + const notifyToolsChanged = createToolsChangedNotifier(); - // Validate session context configuration if provided - let sessionContextResolver: SessionContextResolver | undefined; - if (options.sessionContext) { - validateSessionContextConfig(options.sessionContext); - sessionContextResolver = SessionContextResolver.builder() - .enabled(options.sessionContext.enabled ?? true) - .queryParam(options.sessionContext.queryParam) - .contextResolver(options.sessionContext.contextResolver) - .merge(options.sessionContext.merge ?? "shallow") - .build(); - - // Warn if sessionContext is used with STATIC mode (limited effect) - if (mode === "STATIC" && options.sessionContext.enabled !== false) { - console.warn( - "sessionContext has limited effect in STATIC mode: all clients share the same server instance with base context. " + - "Use DYNAMIC mode for per-session context isolation." - ); - } + // --- Build base server & orchestrator --- + const baseServer: McpServer = options.createServer(); + const baseOrchestrator = buildOrchestrator( + baseServer, options, mode, shouldRegisterMetaTools, notifyToolsChanged + ); + + if (mode === "STATIC") { + await baseOrchestrator.ensureReady(); + } + + // --- Build transport --- + const bundleFactory = createBundleFactory( + options, mode, baseServer, baseOrchestrator, shouldRegisterMetaTools, notifyToolsChanged + ); + const transport = buildTransport( + options, baseOrchestrator.getManager(), bundleFactory, sessionContextResolver + ); + + return { + server: baseServer, + start: () => transport.start(), + close: () => transport.stop(), + }; +} + +// --------------------------------------------------------------------------- +// Named helper functions +// --------------------------------------------------------------------------- + +/** + * Consolidates all upfront validation guards. + * + * @param options - Server creation options to validate + */ +function validateOptions(options: CreateMcpServerOptions): void { + if (options.startup) { + validateStartupConfig(options.startup); } if (typeof options.createServer !== "function") { throw new Error("createMcpServer: `createServer` (factory) is required"); } - const baseServer: McpServer = options.createServer(); +} - // Typed, guarded notifier - type NotifierA = { - server: { notification: (msg: { method: string }) => Promise | void }; - }; - type NotifierB = { notifyToolsListChanged: () => Promise | void }; - const hasNotifierA = (s: unknown): s is NotifierA => - typeof (s as NotifierA)?.server?.notification === "function"; - const hasNotifierB = (s: unknown): s is NotifierB => - typeof (s as NotifierB)?.notifyToolsListChanged === "function"; - - const notifyToolsChanged = async (target: unknown) => { - try { - if (hasNotifierA(target)) { - await target.server.notification({ - method: "notifications/tools/list_changed", - }); - return; - } - if (hasNotifierB(target)) { - await target.notifyToolsListChanged(); - } - } catch (err) { - // Suppress "Not connected" errors - expected when no clients are connected - const errorMessage = err instanceof Error ? err.message : String(err); - if (errorMessage === "Not connected") { - return; // Silently ignore - no clients to notify - } - // Log other errors as they indicate actual problems - console.warn("Failed to send tools list changed notification:", err); - } - }; +/** + * Validates session context config, builds the resolver, and warns about + * limited utility in STATIC mode. + * + * @param options - Server creation options containing sessionContext config + * @param mode - The resolved server mode + * @returns A SessionContextResolver if configured, otherwise undefined + */ +function buildSessionContextResolver( + options: CreateMcpServerOptions, + mode: Exclude +): SessionContextResolver | undefined { + if (!options.sessionContext) return undefined; - const orchestrator = ServerOrchestrator.builder() - .server(baseServer) + validateSessionContextConfig(options.sessionContext); + + const resolver = SessionContextResolver.builder() + .enabled(options.sessionContext.enabled ?? true) + .queryParam(options.sessionContext.queryParam) + .contextResolver(options.sessionContext.contextResolver) + .merge(options.sessionContext.merge ?? "shallow") + .build(); + + if (mode === "STATIC" && options.sessionContext.enabled !== false) { + console.warn( + "sessionContext has limited effect in STATIC mode: all clients share the same server instance with base context. " + + "Use DYNAMIC mode for per-session context isolation." + ); + } + + return resolver; +} + +/** + * Builds a ServerOrchestrator with the standard configuration. Used once for + * the base orchestrator and once per DYNAMIC client. + * + * @param server - The MCP server instance + * @param options - Server creation options (catalog, moduleLoaders, exposurePolicy, startup) + * @param mode - The resolved server mode + * @param shouldRegisterMetaTools - Pre-resolved meta-tools flag + * @param notifyToolsChanged - Notifier function for tool list changes + * @param context - Optional context override (defaults to options.context) + * @returns A configured ServerOrchestrator + */ +function buildOrchestrator( + server: McpServer, + options: CreateMcpServerOptions, + mode: Exclude, + shouldRegisterMetaTools: boolean, + notifyToolsChanged: (target: unknown) => Promise, + context?: unknown +): ServerOrchestrator { + const builder = ServerOrchestrator.builder() + .server(server) .catalog(options.catalog) .moduleLoaders(options.moduleLoaders ?? {}) - .exposurePolicy(options.exposurePolicy!) - .context(options.context) - .notifyToolsListChanged(async () => notifyToolsChanged(baseServer)) - .startup(options.startup!) - .registerMetaTools( - options.registerMetaTools !== undefined - ? options.registerMetaTools - : mode === "DYNAMIC" - ) - .build(); + .context(context !== undefined ? context : options.context) + .notifyToolsListChanged(async () => notifyToolsChanged(server)) + .registerMetaTools(shouldRegisterMetaTools); - // In STATIC mode, wait for initialization to complete before starting - if (mode === "STATIC") { - await orchestrator.ensureReady(); + if (options.exposurePolicy) { + builder.exposurePolicy(options.exposurePolicy); + } + if (options.startup) { + builder.startup(options.startup); } - const transport = new FastifyTransport( - orchestrator.getManager(), - (mergedContext?: unknown) => { - // Create a server + orchestrator bundle - // for a new client when needed - // Use merged context if provided (from session context resolver), - // otherwise fall back to base context - const effectiveContext = mergedContext ?? options.context; - - if (mode === "STATIC") { - // Reuse the base server and orchestrator to avoid duplicate registrations - return { server: baseServer, orchestrator }; - } - const createdServer: McpServer = options.createServer(); - const createdOrchestrator = ServerOrchestrator.builder() - .server(createdServer) - .catalog(options.catalog) - .moduleLoaders(options.moduleLoaders ?? {}) - .exposurePolicy(options.exposurePolicy!) - .context(effectiveContext) - .notifyToolsListChanged(async () => notifyToolsChanged(createdServer)) - .startup(options.startup!) - .registerMetaTools( - options.registerMetaTools !== undefined - ? options.registerMetaTools - : mode === "DYNAMIC" - ) - .build(); - return { server: createdServer, orchestrator: createdOrchestrator }; - }, - options.http, - options.configSchema, - sessionContextResolver, - options.context - ); + return builder.build(); +} - return { - server: baseServer, - start: async () => { - await transport.start(); - }, - close: async () => { - await transport.stop(); - }, +/** + * Creates the bundle factory callback for the transport layer. + * In STATIC mode all clients share one server + orchestrator. + * In DYNAMIC mode a fresh server + orchestrator is created per client. + * + * @param options - Server creation options + * @param mode - STATIC reuses base bundle; DYNAMIC creates fresh per client + * @param baseServer - The shared base server instance + * @param baseOrchestrator - The shared base orchestrator + * @param shouldRegisterMetaTools - Pre-resolved meta-tools flag + * @param notifyToolsChanged - Notifier function for tool list changes + * @returns Bundle factory callback for the transport layer + */ +function createBundleFactory( + options: CreateMcpServerOptions, + mode: Exclude, + baseServer: McpServer, + baseOrchestrator: ServerOrchestrator, + shouldRegisterMetaTools: boolean, + notifyToolsChanged: (target: unknown) => Promise +): CreateBundleCallback { + return (mergedContext?: unknown) => { + if (mode === "STATIC") { + // STATIC: all clients share one server + orchestrator + return { server: baseServer, orchestrator: baseOrchestrator }; + } + + // DYNAMIC: fresh server + orchestrator per client + const effectiveContext = mergedContext ?? options.context; + const clientServer: McpServer = options.createServer(); + const clientOrchestrator = buildOrchestrator( + clientServer, options, mode, shouldRegisterMetaTools, notifyToolsChanged, effectiveContext + ); + return { server: clientServer, orchestrator: clientOrchestrator }; }; } + +/** + * Builds the FastifyTransport using the builder pattern, handling conditional + * `.app()`, `.customEndpoints()`, `.sessionContextResolver()`, and `.baseContext()` chaining. + * + * @param options - Server creation options (http, configSchema, context) + * @param manager - Default DynamicToolManager for status endpoints + * @param bundleFactory - Bundle factory callback for the transport layer + * @param sessionContextResolver - Optional session context resolver + * @returns A configured FastifyTransport + */ +function buildTransport( + options: CreateMcpServerOptions, + manager: ReturnType, + bundleFactory: CreateBundleCallback, + sessionContextResolver: SessionContextResolver | undefined +): FastifyTransport { + const builder = FastifyTransport.builder() + .defaultManager(manager) + .createBundle(bundleFactory) + .host(options.http?.host ?? "0.0.0.0") + .port(options.http?.port ?? 3000) + .basePath(options.http?.basePath ?? "/") + .cors(options.http?.cors ?? true) + .logger(options.http?.logger ?? false); + + if (options.http?.app) { + builder.app(options.http.app); + } + if (options.http?.customEndpoints) { + builder.customEndpoints(options.http.customEndpoints); + } + if (options.configSchema) { + builder.configSchema(options.configSchema); + } + if (sessionContextResolver) { + builder.sessionContextResolver(sessionContextResolver); + } + if (options.context !== undefined) { + builder.baseContext(options.context); + } + + return builder.build(); +} diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index 624d600..9c450a6 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -1,7 +1,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CreatePermissionBasedMcpServerOptions, + ExposurePolicy, } from "../types/index.js"; +import type { McpServerHandle } from "./server.types.js"; import { validatePermissionConfig, createPermissionAwareBundle, @@ -14,30 +16,67 @@ import { PermissionAwareFastifyTransport } from "../permissions/PermissionAwareF export async function createPermissionBasedMcpServer( options: CreatePermissionBasedMcpServerOptions -) { - // Validate that permissions field is provided +): Promise { + // --- Validate --- + validatePermissionOptions(options); + + const sanitizedPolicy = sanitizeExposurePolicyForPermissions(options.exposurePolicy); + const permissionResolver = buildPermissionResolver(options); + + // --- Base server & status-only orchestrator --- + const baseServer: McpServer = options.createServer(); + const baseOrchestrator = buildPermissionOrchestrator(baseServer, options, sanitizedPolicy); + + // --- Per-client bundle factory --- + const createBundle = createPermissionAwareBundle( + createClientOrchestratorFactory(options, sanitizedPolicy), + permissionResolver + ); + + // --- Transport --- + const transport = buildPermissionTransport(options, baseOrchestrator.getManager(), createBundle); + + return { + server: baseServer, + start: () => transport.start(), + close: async () => { + try { + await transport.stop(); + } finally { + permissionResolver.clearCache(); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Named helper functions +// --------------------------------------------------------------------------- + +/** + * Consolidates all upfront validation guards for permission-based servers. + * + * @param options - Server creation options to validate + */ +function validatePermissionOptions( + options: CreatePermissionBasedMcpServerOptions +): void { if (!options.permissions) { throw new Error( "Permission configuration is required for createPermissionBasedMcpServer. " + "Please provide a 'permissions' field in the options." ); } - - // Validate permission configuration validatePermissionConfig(options.permissions); - // Validate session context configuration if provided if (options.sessionContext) { validateSessionContextConfig(options.sessionContext); - // Note: Session context is validated but not yet fully implemented - // for permission-based servers. The base context is used for module loaders. console.warn( "Session context support for permission-based servers is limited. " + "The base context will be used for module loaders." ); } - // Prevent startup.mode configuration - permissions determine toolsets if ((options as any).startup) { throw new Error( "Permission-based servers determine toolsets from client permissions. " + @@ -45,64 +84,95 @@ export async function createPermissionBasedMcpServer( ); } - // Validate createServer factory is provided if (typeof options.createServer !== "function") { throw new Error( "createPermissionBasedMcpServer: `createServer` (factory) is required" ); } +} - // Sanitize exposure policy for permission-based operation - const sanitizedPolicy = sanitizeExposurePolicyForPermissions( - options.exposurePolicy - ); - - // Create permission resolver instance - const permissionResolver = PermissionResolver.builder() +/** + * Builds a PermissionResolver from the options config. + * + * @param options - Server creation options containing permission config + * @returns A configured PermissionResolver + */ +function buildPermissionResolver( + options: CreatePermissionBasedMcpServerOptions +): PermissionResolver { + return PermissionResolver.builder() .source(options.permissions.source) .headerName(options.permissions.headerName ?? "mcp-toolset-permissions") .staticMap(options.permissions.staticMap ?? {}) .resolver(options.permissions.resolver as (clientId: string) => string[]) .defaultPermissions(options.permissions.defaultPermissions ?? []) .build(); +} - // Create base server for default manager (used for status endpoints) - const baseServer: McpServer = options.createServer(); - - // Create base orchestrator for default manager (empty toolsets for status endpoint) - // No notifier needed - STATIC mode with fixed toolsets per client - const baseOrchestrator = ServerOrchestrator.builder() - .server(baseServer) +/** + * Builds a ServerOrchestrator configured for permission-based operation + * (STATIC mode, empty toolsets, no meta-tools, no notifier). + * + * @param server - The MCP server instance + * @param options - Server creation options (catalog, moduleLoaders, context) + * @param policy - Sanitized exposure policy + * @returns Orchestrator configured for permission-based operation + */ +function buildPermissionOrchestrator( + server: McpServer, + options: CreatePermissionBasedMcpServerOptions, + policy: ExposurePolicy | undefined +): ServerOrchestrator { + const builder = ServerOrchestrator.builder() + .server(server) .catalog(options.catalog) .moduleLoaders(options.moduleLoaders ?? {}) - .exposurePolicy(sanitizedPolicy!) .context(options.context) .startup({ mode: "STATIC", toolsets: [] }) - .registerMetaTools(false) - .build(); + .registerMetaTools(false); - // Create permission-aware bundle creator - const createBundle = createPermissionAwareBundle( - (allowedToolsets: string[]) => { - // Create fresh server and orchestrator for each client - const clientServer: McpServer = options.createServer(); - const clientOrchestrator = ServerOrchestrator.builder() - .server(clientServer) - .catalog(options.catalog) - .moduleLoaders(options.moduleLoaders ?? {}) - .exposurePolicy(sanitizedPolicy!) - .context(options.context) - .startup({ mode: "STATIC", toolsets: [] }) - .registerMetaTools(false) - .build(); - return { server: clientServer, orchestrator: clientOrchestrator }; - }, - permissionResolver - ); + if (policy) { + builder.exposurePolicy(policy); + } - // Create permission-aware transport - const transportBuilder = PermissionAwareFastifyTransport.builder() - .defaultManager(baseOrchestrator.getManager()) + return builder.build(); +} + +/** + * Creates the callback that produces a fresh server + orchestrator per client, + * scoped to the client's allowed toolsets. + * + * @param options - Server creation options + * @param policy - Sanitized exposure policy + * @returns Factory callback that accepts allowed toolsets and returns a server/orchestrator pair + */ +function createClientOrchestratorFactory( + options: CreatePermissionBasedMcpServerOptions, + policy: ExposurePolicy | undefined +): (allowedToolsets: string[]) => { server: McpServer; orchestrator: ServerOrchestrator } { + return (allowedToolsets: string[]) => { + const clientServer: McpServer = options.createServer(); + const clientOrchestrator = buildPermissionOrchestrator(clientServer, options, policy); + return { server: clientServer, orchestrator: clientOrchestrator }; + }; +} + +/** + * Builds the PermissionAwareFastifyTransport, handling conditional `.app()` + * and `.customEndpoints()` chaining. + * + * @param options - Server creation options (http config) + * @param manager - Default DynamicToolManager for status endpoints + * @param createBundle - Permission-aware bundle creator + * @returns A configured PermissionAwareFastifyTransport + */ +function buildPermissionTransport( + options: CreatePermissionBasedMcpServerOptions, + manager: ReturnType, + createBundle: ReturnType +): PermissionAwareFastifyTransport { + const builder = PermissionAwareFastifyTransport.builder() + .defaultManager(manager) .createPermissionAwareBundle(createBundle) .host(options.http?.host ?? "0.0.0.0") .port(options.http?.port ?? 3000) @@ -111,28 +181,11 @@ export async function createPermissionBasedMcpServer( .logger(options.http?.logger ?? false); if (options.http?.app) { - transportBuilder.app(options.http.app); + builder.app(options.http.app); } if (options.http?.customEndpoints) { - transportBuilder.customEndpoints(options.http.customEndpoints); + builder.customEndpoints(options.http.customEndpoints); } - const transport = transportBuilder.build(); - - // Return same interface as createMcpServer - return { - server: baseServer, - start: async () => { - await transport.start(); - }, - close: async () => { - try { - // Stop the transport (cleans up client contexts) - await transport.stop(); - } finally { - // Clear permission cache - permissionResolver.clearCache(); - } - }, - }; + return builder.build(); } diff --git a/src/server/server.types.ts b/src/server/server.types.ts index 4c47d27..d146fca 100644 --- a/src/server/server.types.ts +++ b/src/server/server.types.ts @@ -8,6 +8,13 @@ import type { } from "../types/index.js"; import type { FastifyTransportOptions } from "../http/http.types.js"; +/** Handle returned by both `createMcpServer` and `createPermissionBasedMcpServer`. */ +export interface McpServerHandle { + server: McpServer; + start: () => Promise; + close: () => Promise; +} + export interface CreateMcpServerOptions { catalog: ToolSetCatalog; moduleLoaders?: Record; diff --git a/src/server/server.utils.ts b/src/server/server.utils.ts index a8dad8c..538aede 100644 --- a/src/server/server.utils.ts +++ b/src/server/server.utils.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { Mode } from "../types/index.js"; /** * Zod schema for validating startup configuration. @@ -10,3 +11,83 @@ export const startupConfigSchema = z toolsets: z.union([z.array(z.string()), z.literal("ALL")]).optional(), }) .strict(); + +/** + * Validates a startup configuration object against `startupConfigSchema`. + * Throws a descriptive error when the config is invalid. + * + * @param startup - The startup configuration to validate + */ +export function validateStartupConfig( + startup: { mode?: Exclude; toolsets?: string[] | "ALL" } +): void { + try { + startupConfigSchema.parse(startup); + } catch (error) { + if (error instanceof z.ZodError) { + const formatted = error.format(); + throw new Error( + `Invalid startup configuration:\n${JSON.stringify(formatted, null, 2)}\n\n` + + `Hint: Common mistake - use "toolsets" not "initialToolsets"` + ); + } + throw error; + } +} + +/** + * Creates a notifier function that sends `tools/list_changed` notifications + * to an MCP server. Handles two different notification APIs and suppresses + * "Not connected" errors that occur when no clients are connected. + * + * @returns A function that sends tools/list_changed notifications to an MCP server + */ +export function createToolsChangedNotifier(): (target: unknown) => Promise { + type NotifierA = { + server: { notification: (msg: { method: string }) => Promise | void }; + }; + type NotifierB = { notifyToolsListChanged: () => Promise | void }; + + const hasNotifierA = (s: unknown): s is NotifierA => + typeof (s as NotifierA)?.server?.notification === "function"; + const hasNotifierB = (s: unknown): s is NotifierB => + typeof (s as NotifierB)?.notifyToolsListChanged === "function"; + + return async (target: unknown) => { + try { + if (hasNotifierA(target)) { + await target.server.notification({ + method: "notifications/tools/list_changed", + }); + return; + } + if (hasNotifierB(target)) { + await target.notifyToolsListChanged(); + } + } catch (err) { + // Suppress "Not connected" errors - expected when no clients are connected + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage === "Not connected") { + return; // Silently ignore - no clients to notify + } + // Log other errors as they indicate actual problems + console.warn("Failed to send tools list changed notification:", err); + } + }; +} + +/** + * Resolves whether meta-tools should be registered. + * When `explicit` is provided it takes precedence; otherwise meta-tools are + * enabled in DYNAMIC mode and disabled in STATIC mode. + * + * @param explicit - The user-provided registerMetaTools value (undefined = auto) + * @param mode - The resolved server mode + * @returns Whether meta-tools should be registered + */ +export function resolveMetaToolsFlag( + explicit: boolean | undefined, + mode: Exclude +): boolean { + return explicit !== undefined ? explicit : mode === "DYNAMIC"; +} diff --git a/tests/createMcpServer.test.ts b/tests/createMcpServer.test.ts index c01e5f0..c606af0 100644 --- a/tests/createMcpServer.test.ts +++ b/tests/createMcpServer.test.ts @@ -12,6 +12,30 @@ vi.mock("../src/http/FastifyTransport.js", () => { } async start() {} async stop() {} + static builder() { + let _defaultManager: any; + let _createBundle: any; + const opts: any = {}; + let _configSchema: any; + let _sessionContextResolver: any; + let _baseContext: any; + const b = { + defaultManager(v: any) { _defaultManager = v; return b; }, + createBundle(v: any) { _createBundle = v; return b; }, + host(v: any) { opts.host = v; return b; }, + port(v: any) { opts.port = v; return b; }, + basePath(v: any) { opts.basePath = v; return b; }, + cors(v: any) { opts.cors = v; return b; }, + logger(v: any) { opts.logger = v; return b; }, + app(v: any) { opts.app = v; return b; }, + customEndpoints(v: any) { opts.customEndpoints = v; return b; }, + configSchema(v: any) { _configSchema = v; return b; }, + sessionContextResolver(v: any) { _sessionContextResolver = v; return b; }, + baseContext(v: any) { _baseContext = v; return b; }, + build() { return new FastifyTransportMock(_defaultManager, _createBundle, opts, _configSchema, _sessionContextResolver, _baseContext); }, + }; + return b; + } }, }; }); From bc4c3733fe1014adb66c9a4ecd1fc7f2ff3a4070 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 18:10:37 +0200 Subject: [PATCH 07/11] chore: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4053e97..cb0b162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.6.1", + "version": "0.6.2", "private": false, "type": "module", "main": "dist/index.js", From db57ab122c42eaeaf217f26a925b4506b42b64f4 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 18:39:14 +0200 Subject: [PATCH 08/11] chore: doc --- src/permissions/permissions.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/permissions/permissions.types.ts b/src/permissions/permissions.types.ts index 127a273..258682a 100644 --- a/src/permissions/permissions.types.ts +++ b/src/permissions/permissions.types.ts @@ -24,7 +24,7 @@ export interface PermissionAwareFastifyTransportOptions { export interface ClientRequestContext { /** * Unique identifier for the client making the request. - * May be provided via mcp-client-id header or generated as anonymous ID. + * Must be provided via the mcp-client-id header for MCP protocol traffic. */ clientId: string; From a11696e7a30dc2102d21e17778f908174ae423f5 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 22:28:19 +0200 Subject: [PATCH 09/11] chore: cr comments --- src/core/DynamicToolManager.ts | 12 ++++++------ src/core/ServerOrchestrator.ts | 16 +++++++++++----- src/http/FastifyTransport.ts | 6 ++++-- src/server/createPermissionBasedMcpServer.ts | 12 ++++++++---- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/core/DynamicToolManager.ts b/src/core/DynamicToolManager.ts index b840ac9..35c4d72 100644 --- a/src/core/DynamicToolManager.ts +++ b/src/core/DynamicToolManager.ts @@ -81,10 +81,10 @@ export class DynamicToolManager { toolsetName: string, skipNotification = false ): Promise<{ success: boolean; message: string }> { - const earlyExit = this.validateToolsetForEnable(toolsetName); - if (earlyExit) return earlyExit; + const validation = this.validateToolsetForEnable(toolsetName); + if ("message" in validation) return validation; - const sanitized = this.resolver.validateToolsetName(toolsetName).sanitized!; + const { sanitized } = validation; // Check exposure policies BEFORE resolving tools to fail fast const policyCheck = this.checkExposurePolicy(sanitized); @@ -120,11 +120,11 @@ export class DynamicToolManager { /** * @param toolsetName - The raw toolset name to validate - * @returns Early exit result if invalid, or null to continue + * @returns Error result if invalid, or `{ sanitized }` to continue */ private validateToolsetForEnable( toolsetName: string - ): { success: boolean; message: string } | null { + ): { success: boolean; message: string } | { sanitized: string } { const validation = this.resolver.validateToolsetName(toolsetName); if (!validation.isValid || !validation.sanitized) { return { @@ -138,7 +138,7 @@ export class DynamicToolManager { message: `Toolset '${validation.sanitized}' is already enabled.`, }; } - return null; + return { sanitized: validation.sanitized }; } /** diff --git a/src/core/ServerOrchestrator.ts b/src/core/ServerOrchestrator.ts index 446f0c4..9c6242c 100644 --- a/src/core/ServerOrchestrator.ts +++ b/src/core/ServerOrchestrator.ts @@ -34,14 +34,20 @@ export class ServerOrchestrator { options.exposurePolicy?.namespaceToolsWithSetKey ?? true ) .build(); - this.manager = DynamicToolManager.builder() + const managerBuilder = DynamicToolManager.builder() .server(options.server) .resolver(this.resolver) .context(options.context) - .onToolsListChanged(options.notifyToolsListChanged as () => Promise | void) - .exposurePolicy(options.exposurePolicy as ExposurePolicy) - .toolRegistry(toolRegistry) - .build(); + .toolRegistry(toolRegistry); + + if (options.notifyToolsListChanged) { + managerBuilder.onToolsListChanged(options.notifyToolsListChanged); + } + if (options.exposurePolicy) { + managerBuilder.exposurePolicy(options.exposurePolicy); + } + + this.manager = managerBuilder.build(); // Register meta-tools only if requested (default true) if (options.registerMetaTools !== false) { diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index 02902e4..4478151 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -278,7 +278,8 @@ export class FastifyTransport { reply.code(400); return "Missing mcp-client-id"; } - const bundle = this.clientCache.get(clientId); + const { cacheKey } = this.resolveSessionContext(req, clientId); + const bundle = this.clientCache.get(cacheKey); if (!bundle) { reply.code(400); return "Invalid or expired client"; @@ -323,7 +324,8 @@ export class FastifyTransport { id: null, }; } - const bundle = this.clientCache.get(clientId); + const { cacheKey } = this.resolveSessionContext(req, clientId); + const bundle = this.clientCache.get(cacheKey); const transport = bundle?.sessions.get(sessionId); if (!bundle || !transport) { reply.code(404); diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index 9c450a6..f70a3f2 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -100,13 +100,17 @@ function validatePermissionOptions( function buildPermissionResolver( options: CreatePermissionBasedMcpServerOptions ): PermissionResolver { - return PermissionResolver.builder() + const builder = PermissionResolver.builder() .source(options.permissions.source) .headerName(options.permissions.headerName ?? "mcp-toolset-permissions") .staticMap(options.permissions.staticMap ?? {}) - .resolver(options.permissions.resolver as (clientId: string) => string[]) - .defaultPermissions(options.permissions.defaultPermissions ?? []) - .build(); + .defaultPermissions(options.permissions.defaultPermissions ?? []); + + if (options.permissions.resolver) { + builder.resolver(options.permissions.resolver); + } + + return builder.build(); } /** From 8f140205304c258cfb6a001f64cf5fcd6c95be25 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Wed, 11 Feb 2026 23:23:28 +0200 Subject: [PATCH 10/11] chore: intent layer --- AGENTS.md | 73 +++++++---------------------- src/core/AGENTS.md | 83 ++++++++------------------------ src/http/AGENTS.md | 63 ++++++------------------- src/meta/AGENTS.md | 37 +++++++++++++++ src/mode/AGENTS.md | 58 +++++++++-------------- src/permissions/AGENTS.md | 79 +++++++++++-------------------- src/server/AGENTS.md | 99 +++++++-------------------------------- src/session/AGENTS.md | 68 +++++++-------------------- src/types/AGENTS.md | 60 +++++++----------------- 9 files changed, 186 insertions(+), 434 deletions(-) create mode 100644 src/meta/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 0e2256d..f6c90db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,19 +16,7 @@ src/ ├── http/ # FastifyTransport, custom endpoints ├── session/ # SessionContextResolver, ClientResourceCache ├── permissions/ # PermissionResolver, PermissionAwareFastifyTransport -└── errors/ # ToolingError (18 LOC) -``` - -### Data Flow - -``` -Client → HTTP (Fastify) → Per-client MCP Server → ServerOrchestrator - ↓ - DynamicToolManager - ↓ ↓ - ModuleResolver ToolRegistry - ↓ - ModuleLoaders(context) +└── errors/ # ToolingError (18 LOC, thin wrapper — no Intent Node) ``` ### Two Server Modes @@ -46,21 +34,22 @@ Client → HTTP (Fastify) → Per-client MCP Server → ServerOrchestrator ## Code Style -1. **No non-null assertions (`!`)** — If a value might be `undefined`, guard before use or use conditional builder calls. Never use `!` to silence the compiler. -2. **No `as any` in production code** — Permitted only for defensive runtime guards against misconfiguration (e.g. checking a property that doesn't exist on the type) and for SDK boundary mismatches where types are unavailable. Every `as any` should have a comment justifying it. -3. **Named functions over anonymous callbacks** — Extract inline closures longer than ~5 lines into named functions with JSDoc. Helps readability and stack traces. -4. **Builder pattern: conditional calls for optional fields** — When a builder method requires a non-optional param but the source value may be `undefined`, call the method conditionally (`if (value) { builder.method(value); }`) rather than asserting. -5. **Prefer `builder()` over raw constructors** — When a class exposes a builder, use it. Keeps construction style consistent across the codebase. -6. **Named intermediate variables** — Assign computed values (ternaries, function results) to descriptively-named `const` variables before passing them onward. +1. **No non-null assertions (`!`)** — Guard before use or use conditional builder calls. Never use `!` to silence the compiler. +2. **No `as any` in production code** — Permitted only for defensive runtime guards and SDK boundary mismatches. Every `as any` should have a comment justifying it. +3. **Named functions over anonymous callbacks** — Extract inline closures longer than ~5 lines into named functions with JSDoc. +4. **Builder pattern: conditional calls for optional fields** — Call builder methods conditionally (`if (value) { builder.method(value); }`) rather than asserting. +5. **Prefer `builder()` over raw constructors** — When a class exposes a builder, use it. +6. **Named intermediate variables** — Assign computed values to descriptively-named `const` variables before passing them onward. ## Critical Invariants +These are cross-cutting; see leaf Intent Nodes for module-specific invariants. + 1. **All tools → ToolRegistry** — Collision detection happens here only -2. **Disable ≠ Unregister** — MCP SDK limitation; disabled tools remain callable -3. **STATIC + sessionContext** — Session context ignored in STATIC mode -4. **Fail-secure** — Invalid inputs return empty objects, not errors -5. **Silent module failures** — Toolsets activate with partial tools if loaders fail -6. **`mcp-client-id` required for /mcp** — POST, GET, DELETE all reject without header (400) +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` ## Module Index @@ -68,8 +57,9 @@ Read the relevant Intent Node before working in that area: | Module | Intent Node | Covers | |--------|-------------|--------| -| Types | `src/types/AGENTS.md` | All interfaces, contracts, error codes | +| Types | `src/types/AGENTS.md` | Interfaces, contracts, error codes | | Core | `src/core/AGENTS.md` | ServerOrchestrator, DynamicToolManager, ToolRegistry | +| Meta | `src/meta/AGENTS.md` | Meta-tool registration, `_meta` reserved key | | Mode | `src/mode/AGENTS.md` | ModuleResolver, toolset validation | | Server | `src/server/AGENTS.md` | createMcpServer, createPermissionBasedMcpServer | | HTTP | `src/http/AGENTS.md` | FastifyTransport, endpoints, SSE, custom endpoints | @@ -86,38 +76,7 @@ Read the relevant Intent Node before working in that area: - **Discover an anti-pattern** → Add to Anti-patterns section - **Create a new module** → Create a corresponding `AGENTS.md` -Intent Nodes should remain concise (~100 lines max). Focus on what an agent needs to work safely in that area. - -## Consumer Reference - -For agents/LLMs **using** Toolception tools at runtime (not developing the codebase): - -### Meta-tools (DYNAMIC mode) - -- `list_tools()` → `{tools, toolsetToTools}` — Always call first -- `list_toolsets()` → Discover available toolsets -- `enable_toolset({name})` / `disable_toolset({name})` — Runtime control -- `describe_toolset({name})` → Toolset details - -Tools are namespaced by toolset (e.g., `search.find`). Error responses include `{success, message}`. - -### HTTP Endpoints - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/healthz` | Health check | -| GET | `/tools` | Tool/toolset status | -| POST | `/mcp` | JSON-RPC (requires `mcp-client-id`) | -| GET | `/mcp` | SSE stream (requires `mcp-client-id`) | -| DELETE | `/mcp` | Close session (requires `mcp-client-id`) | -| GET | `/.well-known/mcp-config` | Config schema | - -### Headers - -- `mcp-client-id`: **Required** for `/mcp` endpoints. Stable client identifier. -- `mcp-session-id`: Session ID from server (managed by transport after initialize) -- `mcp-toolset-permissions`: Comma-separated toolsets (permission-based, header source) -- `config` query param: Base64-encoded JSON for per-session context (if enabled) +Intent Nodes should remain concise (~100 lines max). Focus on what an agent needs to work safely in that area — hidden knowledge that cannot be derived by reading the source code. --- *This is the Intent Layer root. See leaf nodes for module-specific detail.* diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index 85cfaee..41556d3 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -2,88 +2,45 @@ ## Purpose -Central orchestration layer that wires together all components. Manages toolset lifecycle, tool registration, and server initialization. +Central orchestration layer. Manages toolset lifecycle, tool registration, and server initialization. ## Key Components -**ServerOrchestrator** (`ServerOrchestrator.ts`) -- Entry point combining ModuleResolver, DynamicToolManager, ToolRegistry -- `ensureReady()`: Async - waits for initialization, throws stored errors -- `isReady()`: Non-throwing alternative for health checks -- `getMode()`: Returns resolved DYNAMIC or STATIC mode -- `getManager()`: Access to DynamicToolManager - -**DynamicToolManager** (`DynamicToolManager.ts`) -- `enableToolset(name, skipNotification?)`: Full validation + registration flow -- `disableToolset(name)`: State-only operation (see invariant #2) -- `enableToolsets(names)`: Batch enable with single notification -- `checkExposurePolicy(name)`: Validates against allowlist/denylist/maxActive -- `getStatus()`: Returns available/active toolsets, tools list, toolset→tools map - -**ToolRegistry** (`ToolRegistry.ts`) -- `getSafeName(toolsetKey, toolName)`: Applies namespacing if enabled -- `has(name)`: Collision detection -- `add(name)` / `addForToolset(toolsetKey, name)`: Registration with collision check -- `mapAndValidate(toolsetKey, tools)`: Transforms tools with safe names, checks collisions +- **ServerOrchestrator** — Entry point wiring ModuleResolver + DynamicToolManager + ToolRegistry. Resolves startup mode, initializes toolsets, optionally registers meta-tools. +- **DynamicToolManager** — Enable/disable toolsets at runtime. Validates names, checks exposure policy, resolves tools via ModuleResolver, registers via ToolRegistry. +- **ToolRegistry** — Tool name registry with collision detection and optional namespacing (`toolset.toolName`). ## Invariants -1. **All tools go through ToolRegistry** - Collision detection happens here only -2. **Disable is state-only** - MCP SDK cannot unregister tools; disabled toolsets' tools remain callable -3. **Notifications may fail silently** - "Not connected" errors are expected and swallowed -4. **Namespacing applied BEFORE collision check** - `mapAndValidate()` generates safe names first -5. **Toolset added to activeToolsets AFTER registration** - Partial failures leave toolset inactive - -## Meta-tools Registration - -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()`. +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. +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. -**DYNAMIC mode only:** -- `enable_toolset` / `disable_toolset` - Runtime toolset management -- `list_toolsets` / `describe_toolset` - Discovery +## Enable Toolset Flow -**Both modes:** -- `list_tools` - List registered tool names (includes meta-tools) +``` +enableToolset(name) + → validateToolsetForEnable(name) → fail fast if invalid/already active + → checkExposurePolicy(name) → fail fast if denied by allowlist/denylist/maxActive + → resolveAndRegisterTools(name) → ModuleResolver + ToolRegistry + → activeToolsets.add(name) — only after successful registration + → notifyToolsChanged() — unless skipNotification +``` ## 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 - -``` -enableToolset(name) - ↓ -ModuleResolver.validateToolsetName(name) - ↓ -checkExposurePolicy(name) → fail fast if denied - ↓ -ModuleResolver.resolveToolsForToolsets([name], context) - ↓ -ToolRegistry.mapAndValidate(name, tools) → apply namespacing - ↓ -For each tool: registerSingleTool(tool, name) - ↓ -activeToolsets.add(name) - ↓ -notifyToolsChanged() (unless skipNotification) -``` +- Using `_meta` as a toolset key in the catalog (reserved for meta-tools — see `src/meta/AGENTS.md`) ## Dependencies - Imports: `src/types`, `src/mode`, `src/meta` - Used by: `src/server/*`, `src/http/*` -## See Also - -- `src/mode/AGENTS.md` - How tools are resolved -- `src/server/AGENTS.md` - How orchestrator is created -- `src/types/AGENTS.md` - ExposurePolicy, ToolingErrorCode - --- *Keep this Intent Node updated when modifying core orchestration. See root AGENTS.md for maintenance guidelines.* diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index 22a177b..94f6eda 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -6,15 +6,8 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, ## Key Components -**FastifyTransport** (`FastifyTransport.ts`) -- Main HTTP transport using Fastify -- Per-client bundles via ClientResourceCache -- Optional SessionContextResolver for context differentiation - -**Custom Endpoints** (`customEndpoints.ts`, `endpointRegistration.ts`) -- Type-safe endpoint definitions with Zod schemas -- `defineEndpoint()` / `definePermissionAwareEndpoint()` helpers -- Automatic request/response validation +- **FastifyTransport** (`FastifyTransport.ts`) — Main HTTP transport. Per-client bundles via ClientResourceCache, optional SessionContextResolver for context differentiation. +- **Custom Endpoints** (`customEndpoints.ts`, `endpointRegistration.ts`) — Type-safe endpoint definitions with Zod schemas. `defineEndpoint()` / `definePermissionAwareEndpoint()` helpers. ## Endpoints @@ -29,59 +22,37 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, ## Invariants -1. **`mcp-client-id` header required** - All MCP protocol endpoints (POST, GET, DELETE) reject requests without this header (400). Custom endpoints still generate `anon-${UUID}` fallback. -2. **Session created on POST /mcp initialize** - Tracked in `bundle.sessions` Map -3. **Cache key format** - `${clientId}:${contextHash}` when session context differs -4. **Reserved paths cannot be overridden** - `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` - -## Request Extraction +1. **`mcp-client-id` header required** — All `/mcp` endpoints reject without it (400). Custom endpoints auto-generate `anon-${UUID}` fallback instead. +2. **Session created on POST /mcp initialize** — `isInitializeRequest()` from MCP SDK detects first-contact requests. New session ID generated via `randomUUID()`, stored in `bundle.sessions` Map. +3. **Cache key uses `resolveSessionContext()`** — All three `/mcp` handlers (POST, GET, DELETE) must derive the cache key through the same `resolveSessionContext()` path. Using plain `clientId` causes cache misses when session context is active. +4. **Reserved paths cannot be overridden** — `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` are registered before custom endpoints. -```typescript -// Headers normalized to lowercase -headers: Record +## SDK Boundary Workarounds -// Query params filtered to string values -query: Record +These `as any` casts exist because of MCP SDK / Fastify type mismatches: -// Client ID from header (required for /mcp endpoints, auto-generated for custom endpoints) -clientId: headers['mcp-client-id'] -``` +- **Fastify raw req/res passthrough** — `transport.handleRequest((req as any).raw, (reply as any).raw, body)`. The SDK expects Node `http.IncomingMessage`/`http.ServerResponse` but Fastify wraps these objects. +- **`StreamableHTTPServerTransport.close()`** — The DELETE handler calls `(transport as any).close()` because `.close()` exists at runtime but is not in the SDK's TypeScript types. ## Session Lifecycle ``` 1. POST /mcp (initialize) + → isInitializeRequest(body) detects first contact → Create StreamableHTTPServerTransport - → Generate session ID - → Store in bundle.sessions + → Generate session ID, store in bundle.sessions 2. GET /mcp (streaming) → Require mcp-session-id header → Delegate to transport.handleRequest() - → Maintain SSE connection 3. POST /mcp (subsequent) - → Use existing session + → Look up existing session by mcp-session-id → Route JSON-RPC through transport 4. DELETE /mcp (cleanup) + → Close transport (best-effort) → Remove session from bundle - → Close transport -``` - -## Custom Endpoint Registration - -```typescript -// Standard endpoint -defineEndpoint({ - path: '/my-endpoint', - method: 'POST', - body: z.object({ data: z.string() }), - handler: async (req, manager) => ({ result: 'ok' }) -}) - -// Permission-aware (includes allowedToolsets, failedToolsets) -definePermissionAwareEndpoint({...}) ``` ## Anti-patterns @@ -95,11 +66,5 @@ definePermissionAwareEndpoint({...}) - Imports: `src/types`, `src/core`, `src/session` - Used by: `src/server/createMcpServer` -## See Also - -- `src/session/AGENTS.md` - SessionContextResolver, ClientResourceCache -- `src/permissions/AGENTS.md` - PermissionAwareFastifyTransport -- `src/server/AGENTS.md` - How transport is configured - --- *Keep this Intent Node updated when modifying HTTP transport. See root AGENTS.md for maintenance guidelines.* diff --git a/src/meta/AGENTS.md b/src/meta/AGENTS.md new file mode 100644 index 0000000..1cae89f --- /dev/null +++ b/src/meta/AGENTS.md @@ -0,0 +1,37 @@ +# Meta Module + +## Purpose + +Registers meta-tools on MCP servers for runtime toolset management. Called by ServerOrchestrator during initialization. + +## Key Components + +- **registerMetaTools** (`registerMetaTools.ts`) — Registers meta-tools on a server, scoped by mode. Uses ToolRegistry for collision detection (same path as user-defined tools). +- **META_TOOLSET_KEY** — Constant `"_meta"`. All meta-tools are registered under this reserved toolset key. + +## Mode-Dependent Registration + +| Mode | Tools Registered | +|------|-----------------| +| DYNAMIC | `enable_toolset`, `disable_toolset`, `list_toolsets`, `describe_toolset`, `list_tools` | +| STATIC | `list_tools` only | + +## Invariants + +1. **`_meta` is a reserved toolset key** — If a catalog uses `"_meta"` as a toolset key, ToolRegistry will detect a name collision at registration time. Never use this key in the catalog. +2. **Meta-tools registered via ToolRegistry** — Same collision detection path as user tools. Meta-tools appear in `toolRegistry.list()` and `toolRegistry.listByToolset()`. +3. **`enable_toolset` / `disable_toolset` annotated with `destructiveHint: true`** — Visible to LLM clients via MCP tool annotations. Discovery tools (`list_*`, `describe_*`) use `readOnlyHint: true`. + +## Anti-patterns + +- Using `_meta` as a catalog key (collision with meta-tools) +- Registering meta-tools outside of `registerMetaTools()` (bypasses ToolRegistry) +- Expecting meta-tools in permission-based servers (they are never registered — `registerMetaTools(false)`) + +## Dependencies + +- Imports: `src/types`, `src/core/DynamicToolManager`, `src/core/ToolRegistry` +- Used by: `src/core/ServerOrchestrator` + +--- +*Keep this Intent Node updated when modifying meta-tools. See root AGENTS.md for maintenance guidelines.* diff --git a/src/mode/AGENTS.md b/src/mode/AGENTS.md index 6b7e73d..6b3c303 100644 --- a/src/mode/AGENTS.md +++ b/src/mode/AGENTS.md @@ -2,60 +2,44 @@ ## Purpose -Handles toolset resolution, validation, and startup mode determination. Provides the bridge between toolset definitions and tool instances. +Handles toolset resolution, validation, and module loading. Bridges toolset definitions and tool instances. ## Key Components -**ModuleResolver** (`ModuleResolver.ts`) -- `getAvailableToolsets()`: Returns all toolset keys from catalog -- `getToolsetDefinition(name)`: Retrieves single toolset definition -- `validateToolsetName(name)`: Returns `{isValid, sanitized, error}` -- `resolveToolsForToolsets(toolsets, context)`: Async - collects direct tools + module-loaded tools - -**ToolsetValidator** (`ModeResolver.ts`) -- `resolveMode(env?, args?)`: Returns "DYNAMIC", "STATIC", or null -- `validateToolsetName(name, catalog)`: Validates against provided catalog -- `parseCommaSeparatedToolSets(input, catalog)`: Parses toolset strings -- `validateToolsetModules(toolsetNames, catalog)`: Returns modules for valid toolsets +- **ModuleResolver** (`ModuleResolver.ts`) — Stores catalog, validates toolset names, resolves tools by collecting direct tools + calling module loaders. +- **ToolsetValidator** (`ModeResolver.ts`) — Validates toolset names against a provided catalog, parses comma-separated toolset strings. Also resolves startup mode from env/args. ## Invariants -1. **Module loaders fail silently** - Errors are caught, logged as warnings, and skipped; toolset activates with partial tools -2. **ToolsetValidator is in ModeResolver.ts** - Historical naming quirk: `ToolsetValidator.ts` just re-exports from `ModeResolver.ts` -3. **ModuleResolver stores catalog; ToolsetValidator takes it as parameter** - Different validation signatures -4. **Context passed to module loaders** - Enables dynamic tool generation based on runtime context - -## Anti-patterns - -- Throwing on invalid toolset names (return validation result instead) -- Assuming module loaders are synchronous (always await) -- Using ToolsetValidator.ts directly (it's just a re-export) +1. **Module loaders fail silently** — Errors caught, logged as warnings, skipped. Toolset activates with whatever tools succeeded. This is intentional — one broken loader should not block the rest. +2. **ToolsetValidator is in ModeResolver.ts** — Historical naming quirk. `ToolsetValidator.ts` just re-exports from `ModeResolver.ts`. Import from either file. +3. **ModuleResolver stores catalog; ToolsetValidator takes it as parameter** — Different validation signatures. ModuleResolver validates against its stored catalog; ToolsetValidator is stateless. +4. **Context passed to module loaders** — Enables dynamic tool generation based on runtime context (e.g., per-client tools). ## Module Loading Flow ``` resolveToolsForToolsets([toolsetName], context) - ↓ -For each toolset: - 1. Collect direct tools from definition.tools[] - 2. For each module in definition.modules[]: - - Look up loader in moduleLoaders map - - Call loader(context) - may be async - - Catch errors → warn and skip - - Append returned tools - ↓ -Return flattened McpToolDefinition[] + For each toolset: + 1. Collect direct tools from definition.tools[] + 2. For each module in definition.modules[]: + → Look up loader in moduleLoaders map + → Call loader(context) — may be async + → Catch errors → warn and skip (invariant #1) + → Append returned tools + Return flattened McpToolDefinition[] ``` +## Anti-patterns + +- Throwing on invalid toolset names (return validation result instead) +- Assuming module loaders are synchronous (always await) +- Using `ToolsetValidator.ts` directly when `ModeResolver.ts` is clearer + ## Dependencies - Imports: `src/types` - Used by: `src/core/ServerOrchestrator`, `src/core/DynamicToolManager` -## See Also - -- `src/core/AGENTS.md` - How resolved tools are registered -- `src/types/AGENTS.md` - ModuleLoader type definition - --- *Keep this Intent Node updated when modifying mode resolution. See root AGENTS.md for maintenance guidelines.* diff --git a/src/permissions/AGENTS.md b/src/permissions/AGENTS.md index adba01e..896e459 100644 --- a/src/permissions/AGENTS.md +++ b/src/permissions/AGENTS.md @@ -6,86 +6,61 @@ Provides per-client access control for toolsets. Supports header-based and confi ## Key Components -**PermissionResolver** (`PermissionResolver.ts`) -- `resolve(clientId, headers?)`: Returns allowed toolset names -- `invalidateCache(clientId)`: Clear specific client's cached permissions -- `clearCache()`: Clear all cached permissions - -**validatePermissionConfig** (`validatePermissionConfig.ts`) -- Validates PermissionConfig structure -- Ensures source is "headers" or "config" -- For config source: requires staticMap or resolver - -**createPermissionAwareBundle** (`createPermissionAwareBundle.ts`) -- Wraps bundle creation with permission enforcement -- Returns `{server, orchestrator, allowedToolsets, failedToolsets}` -- Throws if ALL requested toolsets fail (likely config error) - -**PermissionAwareFastifyTransport** (`PermissionAwareFastifyTransport.ts`) -- HTTP transport with permission enforcement -- Per-client bundles via ClientResourceCache -- MCP endpoints require `mcp-client-id` header (400 if missing) +- **PermissionResolver** (`PermissionResolver.ts`) — Resolves allowed toolsets for a client. Supports header and config sources with caching. +- **validatePermissionConfig** (`permissions.utils.ts`) — Validates PermissionConfig structure at startup. +- **createPermissionAwareBundle** (`permissions.utils.ts`) — Wraps bundle creation with permission enforcement. Returns `{server, orchestrator, allowedToolsets, failedToolsets}`. +- **sanitizeExposurePolicyForPermissions** (`permissions.utils.ts`) — Strips `allowlist`, `denylist`, and `maxActiveToolsets` from ExposurePolicy with warnings. Permissions handle access control instead — these policy fields would conflict. +- **PermissionAwareFastifyTransport** (`PermissionAwareFastifyTransport.ts`) — HTTP transport variant with per-client permission enforcement via ClientResourceCache. ## Invariants -1. **Permission cache has no TTL** - Must call `invalidateCache()` manually when permissions change -2. **Header lookup is case-insensitive** - Per RFC 7230 -3. **Fail-secure resolution** - Invalid permissions return empty array, not error -4. **All toolsets fail = throw** - Partial success continues with warning +1. **Permission cache has no TTL** — Must call `invalidateCache()` or `clearCache()` manually when permissions change. There is no automatic expiration. +2. **Header lookup is case-insensitive** — Per RFC 7230. +3. **Fail-secure resolution** — Invalid permissions return empty array, not error. Client gets no toolsets. +4. **All toolsets fail = throw** — If every requested toolset fails to enable, it's likely a config error. Partial success continues with warning. ## Resolution Priority (config source) ``` 1. resolver(clientId) → if provided and returns valid array -2. staticMap[clientId] → if provided and client exists -3. defaultPermissions → fallback -4. [] → if nothing else +2. staticMap[clientId] → if client exists in map +3. defaultPermissions → fallback array +4. [] → if nothing else (fail-secure) ``` ## Caching Behavior **PermissionResolver cache:** - Keyed by clientId -- NO automatic expiration -- Manual invalidation required +- NO automatic expiration — manual `invalidateCache(clientId)` or `clearCache()` required +- This is a deliberate design choice: permissions change infrequently, and stale cache is preferable to repeated resolver calls **PermissionAwareFastifyTransport cache:** - Keyed by clientId (all MCP clients, since header is required) -- LRU eviction with onEvict cleanup -- Closes all sessions in bundle on eviction - -## Anti-patterns - -- Expecting cache to auto-invalidate (it won't) -- Sending MCP protocol requests without `mcp-client-id` header (returns 400) -- Trusting client-provided permissions without config-based fallback +- LRU eviction with onEvict cleanup (closes all sessions in bundle) ## Permission Flow ``` HTTP Request with mcp-client-id header - ↓ -PermissionAwareFastifyTransport extracts client context - ↓ -createPermissionAwareBundle(context) - ↓ -PermissionResolver.resolve(clientId, headers) - ↓ -Create STATIC mode server with allowed toolsets only - ↓ -Return bundle (cached for reuse) + → PermissionAwareFastifyTransport extracts client context + → createPermissionAwareBundle(context) + → PermissionResolver.resolve(clientId, headers) + → Create STATIC mode server with allowed toolsets only + → Return bundle (cached for reuse) ``` +## Anti-patterns + +- Expecting cache to auto-invalidate (it won't — see invariant #1) +- Sending MCP protocol requests without `mcp-client-id` header (returns 400) +- Trusting client-provided permissions without config-based fallback +- Providing allowlist/denylist to permission-based server (stripped silently by `sanitizeExposurePolicyForPermissions`) + ## Dependencies - Imports: `src/types`, `src/core`, `src/session` - Used by: `src/server/createPermissionBasedMcpServer` -## See Also - -- `src/http/AGENTS.md` - FastifyTransport base class -- `src/server/AGENTS.md` - createPermissionBasedMcpServer -- `src/types/AGENTS.md` - PermissionConfig type - --- *Keep this Intent Node updated when modifying permissions. See root AGENTS.md for maintenance guidelines.* diff --git a/src/server/AGENTS.md b/src/server/AGENTS.md index 9bf6267..437b9c2 100644 --- a/src/server/AGENTS.md +++ b/src/server/AGENTS.md @@ -6,102 +6,43 @@ Factory functions for creating MCP servers. Provides standard and permission-bas ## Key Components -**createMcpServer** (`createMcpServer.ts`) -- Standard server factory supporting DYNAMIC or STATIC modes -- Returns `McpServerHandle` (`{server, start(), close()}`) -- Supports session context for multi-tenancy -- Main flow delegates to named helpers: `validateOptions`, `buildSessionContextResolver`, `buildOrchestrator`, `createBundleFactory`, `buildTransport` - -**createPermissionBasedMcpServer** (`createPermissionBasedMcpServer.ts`) -- Permission-controlled server factory -- Always STATIC mode per client -- No meta-tools (clients can't change toolsets) -- Main flow delegates to named helpers: `validatePermissionOptions`, `buildPermissionResolver`, `buildPermissionOrchestrator`, `createClientOrchestratorFactory`, `buildPermissionTransport` - -**Shared types** (`server.types.ts`) -- `CreateMcpServerOptions` — options for standard server -- `McpServerHandle` — return type for both factories (`{server, start, close}`) - -**Shared utilities** (`server.utils.ts`) -- `validateStartupConfig(startup)` — Zod parse + error formatting -- `createToolsChangedNotifier()` — encapsulated notifier pattern (type guards as closure) -- `resolveMetaToolsFlag(explicit, mode)` — meta-tools default: on for DYNAMIC, off for STATIC - -## createMcpServer Options - -```typescript -{ - catalog: ToolSetCatalog, // Required - createServer: () => McpServer, // Required - factory function - moduleLoaders?: Record, - exposurePolicy?: ExposurePolicy, - context?: unknown, - startup?: { - mode?: "DYNAMIC" | "STATIC", - toolsets?: string[] | "ALL" - }, - registerMetaTools?: boolean, // Default: true for DYNAMIC - http?: FastifyTransportOptions, - sessionContext?: SessionContextConfig -} -``` - -## createPermissionBasedMcpServer Options - -```typescript -{ - catalog: ToolSetCatalog, // Required - createServer: () => McpServer, // Required - permissions: PermissionConfig, // Required - moduleLoaders?: Record, - exposurePolicy?: ExposurePolicy, // Sanitized - see invariant #4 - context?: unknown, - http?: FastifyTransportOptions, - sessionContext?: SessionContextConfig // Limited support -} -``` +- **createMcpServer** (`createMcpServer.ts`) — Standard server factory supporting DYNAMIC or STATIC modes. Returns `McpServerHandle`. Delegates to named helpers: `validateOptions`, `buildSessionContextResolver`, `buildOrchestrator`, `createBundleFactory`, `buildTransport`. +- **createPermissionBasedMcpServer** (`createPermissionBasedMcpServer.ts`) — Permission-controlled server factory. Always STATIC per client, no meta-tools. Delegates to: `validatePermissionOptions`, `buildPermissionResolver`, `buildPermissionOrchestrator`, `createClientOrchestratorFactory`, `buildPermissionTransport`. +- **McpServerHandle** (`server.types.ts`) — Shared return type: `{server, start(), close()}`. +- **Shared utilities** (`server.utils.ts`) — `validateStartupConfig`, `createToolsChangedNotifier`, `resolveMetaToolsFlag`. ## Invariants -1. **Startup config validated via Zod .strict()** - Catches typos in config keys -2. **STATIC + sessionContext = warning** - Session context has limited effect in STATIC mode -3. **Permission-based servers ignore startup field** - Throws if `startup` provided -4. **Permission-based sanitizes exposure policy** - Strips allowlist/denylist/maxActiveToolsets with warnings -5. **Orchestrator.ensureReady() before start** - STATIC mode waits for initialization -6. **Orchestrator built via conditional builder calls** - Optional fields (`exposurePolicy`, `startup`) are only passed to the builder when defined; never use `!` to coerce them +1. **Startup config validated via Zod `.strict()`** — Catches typos in config keys (e.g., `initialToolsets` instead of `toolsets`). +2. **STATIC + sessionContext = warning** — Session context has limited effect in STATIC mode since all clients share one server. +3. **Permission-based servers reject `startup` field** — Throws if `startup` provided. Toolsets come from permissions, not config. +4. **Permission-based sanitizes exposure policy** — `sanitizeExposurePolicyForPermissions` strips allowlist/denylist/maxActiveToolsets with warnings. +5. **`ensureReady()` before transport start** — STATIC mode waits for toolset initialization to complete. +6. **Orchestrator built via conditional builder calls** — Optional fields (`exposurePolicy`, `startup`) passed to builder only when defined. Never use `!` to coerce. -## Mode Selection Logic +## Notifier Pattern -``` -If startup.mode specified: - → Use specified mode -Else if startup.toolsets === "ALL": - → STATIC mode, enable all -Else if startup.toolsets is array: - → STATIC mode, enable specified -Else: - → DYNAMIC mode (default) -``` +`createToolsChangedNotifier()` returns a function that duck-types the MCP server to find notification capability. Two type-guard paths exist (NotifierA / NotifierB) because the MCP SDK exposes `server.notification()` through different interfaces depending on connection state. "Not connected" errors are expected during SSE disconnect and are swallowed silently. ## Server Architecture **Standard (createMcpServer):** ``` -DYNAMIC: Per-client ServerOrchestrator + MCP Server -STATIC: Shared ServerOrchestrator, tools pre-loaded +DYNAMIC: Per-client ServerOrchestrator + MCP Server (fresh per bundle) +STATIC: Shared singleton — all clients reuse one server + orchestrator ``` **Permission-based (createPermissionBasedMcpServer):** ``` -Base orchestrator (empty) for status endpoints -Per-client orchestrators loaded with allowed toolsets +Base orchestrator (empty, STATIC) for /tools status endpoint +Per-client orchestrators loaded with allowed toolsets only Always STATIC per client, no meta-tools ``` ## Anti-patterns - Providing `startup` to permission-based server (throws) -- Expecting allowlist/denylist to work with permission-based (ignored) +- Expecting allowlist/denylist to work with permission-based (stripped silently) - Not awaiting `start()` before accepting requests - Using non-null assertions (`!`) on optional builder params — use conditional calls instead @@ -110,11 +51,5 @@ Always STATIC per client, no meta-tools - Imports: `src/types`, `src/core`, `src/http`, `src/session`, `src/permissions` - Used by: Application entry points -## See Also - -- `src/core/AGENTS.md` - ServerOrchestrator internals -- `src/http/AGENTS.md` - Transport layer -- `src/permissions/AGENTS.md` - Permission resolution - --- *Keep this Intent Node updated when modifying server creation. See root AGENTS.md for maintenance guidelines.* diff --git a/src/session/AGENTS.md b/src/session/AGENTS.md index 3831f1b..4dd715e 100644 --- a/src/session/AGENTS.md +++ b/src/session/AGENTS.md @@ -6,71 +6,39 @@ Manages per-session context resolution and client resource caching. Enables mult ## Key Components -**SessionContextResolver** (`SessionContextResolver.ts`) -- `resolve(request, baseContext)`: Main entry - returns `{context, cacheKeySuffix}` -- `parseQueryConfig()`: Extracts config from query param (base64 or JSON) -- `filterAllowedKeys()`: Whitelists config keys -- `mergeContexts()`: Combines base + session context -- `generateCacheKeySuffix()`: SHA-256 hash (first 16 chars) of sorted config - -**ClientResourceCache** (`ClientResourceCache.ts`) -- Generic LRU cache with TTL support -- `get(key)`: Returns resource, updates LRU position -- `set(key, resource)`: Stores with auto-eviction at max capacity -- `delete(key)`: Manual removal with cleanup callback -- `stop(clearEntries?)`: Stops background pruning - -**validateSessionContextConfig** (`validateSessionContextConfig.ts`) -- Validates SessionContextConfig structure -- Checks encoding, allowedKeys, merge strategy +- **SessionContextResolver** (`SessionContextResolver.ts`) — Extracts config from query params, filters by allowed keys, merges with base context, generates deterministic cache key suffix. +- **ClientResourceCache** (`ClientResourceCache.ts`) — Generic LRU cache with TTL. Supports onEvict cleanup callbacks and background pruning. +- **validateSessionContextConfig** (`session.utils.ts`) — Validates SessionContextConfig structure at startup. ## Invariants -1. **Fail-secure parsing** - Invalid encoding returns `{}`, not error -2. **Disallowed keys silently filtered** - No logging to prevent information leakage -3. **Cache key includes context hash** - Format: `${clientId}:${cacheKeySuffix}` -4. **LRU eviction triggers cleanup** - onEvict callback called on removal -5. **Background pruning interval** - Default 10 minutes, removes expired entries +1. **Fail-secure parsing** — Invalid base64/JSON encoding returns `{}`, not an error. Malformed client input never crashes the server. +2. **Disallowed keys silently filtered** — No logging to prevent information leakage about what keys exist. +3. **Cache key includes context hash** — Format: `${clientId}:${sha256suffix}`. Uses SHA-256 of sorted config keys — property order does not affect the hash. +4. **LRU eviction triggers cleanup** — onEvict callback called on removal. Transport uses this to close all sessions in an evicted bundle. +5. **Background pruning interval** — Removes TTL-expired entries periodically. `stop(true)` clears all entries and triggers cleanup. -## Cache Defaults +## Context Resolution Flow -- `maxSize`: 1000 entries -- `ttlMs`: 3600000 (1 hour) -- `pruneIntervalMs`: 600000 (10 minutes) +``` +HTTP Request with ?config=base64_encoded_data + → parseQueryConfig() — decode base64 or JSON + → filterAllowedKeys() — whitelist enforcement (security boundary) + → mergeContexts() — shallow or deep merge with base context + → generateCacheKeySuffix() — SHA-256 hash (first 16 chars, sorted keys) + → Return {context, cacheKeySuffix} +``` ## Anti-patterns -- Omitting allowedKeys (security risk - allows arbitrary config injection) +- Omitting `allowedKeys` (security risk — allows arbitrary config injection) - Assuming cache entries persist (TTL and LRU can evict anytime) - Blocking on cache operations (async evict callbacks are fire-and-forget) -## Context Resolution Flow - -``` -HTTP Request with ?config=base64_encoded_data - ↓ -SessionContextResolver.resolve(request, baseContext) - ↓ -parseQueryConfig() → decode base64/JSON - ↓ -filterAllowedKeys() → whitelist enforcement - ↓ -mergeContexts() → shallow or deep merge - ↓ -generateCacheKeySuffix() → deterministic hash - ↓ -Return {context, cacheKeySuffix} -``` - ## Dependencies - Imports: `src/types` - Used by: `src/http/FastifyTransport`, `src/server/createMcpServer` -## See Also - -- `src/http/AGENTS.md` - How session context integrates with HTTP transport -- `src/types/AGENTS.md` - SessionContextConfig type definition - --- *Keep this Intent Node updated when modifying session handling. See root AGENTS.md for maintenance guidelines.* diff --git a/src/types/AGENTS.md b/src/types/AGENTS.md index eb9b67e..6e05f84 100644 --- a/src/types/AGENTS.md +++ b/src/types/AGENTS.md @@ -2,61 +2,33 @@ ## Purpose -Defines all TypeScript interfaces, types, and contracts for the Toolception system. This is the foundation layer with no internal dependencies. +Defines all TypeScript interfaces, types, and contracts. Foundation layer with no internal dependencies. -## Key Components +## Key Types -**McpToolDefinition** - Individual tool structure with name, description, inputSchema, handler, and optional annotations. +- **McpToolDefinition** — Individual tool: name, description, inputSchema, handler, optional annotations +- **ToolSetDefinition** — Groups tools with optional module references and decision criteria for LLMs +- **ToolSetCatalog** — `Record` mapping toolset keys to definitions +- **ExposurePolicy** — Controls toolset access: allowlist/denylist, max active count, namespacing +- **PermissionConfig** — Per-client access control with header or config source +- **SessionContextConfig** — Per-session context extraction from query params +- **ToolingErrorCode** — Error classification: `E_VALIDATION`, `E_POLICY_MAX_ACTIVE`, `E_TOOL_NAME_CONFLICT`, `E_NOTIFY_FAILED`, `E_INTERNAL` +- **ModuleLoader** — `(context?) => Promise | McpToolDefinition[]` -**ToolSetDefinition** - Groups tools into named sets with optional module references and decision criteria. - -**ToolSetCatalog** - `Record` mapping toolset keys to definitions. - -**ExposurePolicy** - Controls toolset access: -- `maxActiveToolsets`: Concurrent limit -- `allowlist` / `denylist`: Toolset filtering -- `namespaceToolsWithSetKey`: Prefix tools with toolset name -- `onLimitExceeded`: Callback when limit hit - -**PermissionConfig** - Per-client access control: -- `source: "headers" | "config"` - Permission data source -- `headerName`: Custom header (default: `mcp-toolset-permissions`) -- `staticMap`: clientId → toolsets mapping -- `resolver`: Dynamic permission function -- `defaultPermissions`: Fallback array - -**SessionContextConfig** - Per-session context extraction: -- `queryParam`: name, encoding (base64/json), allowedKeys -- `contextResolver`: Custom context builder -- `merge`: "shallow" | "deep" - -**ToolingErrorCode** - Error classification enum: -- `E_VALIDATION`, `E_POLICY_MAX_ACTIVE`, `E_TOOL_NAME_CONFLICT`, `E_NOTIFY_FAILED`, `E_INTERNAL` - -**ModuleLoader** - `(context?) => Promise | McpToolDefinition[]` +Also: `src/errors/ToolingError.ts` (18 LOC) — thin error class wrapping ToolingErrorCode. No separate Intent Node needed. ## Invariants -1. **Annotations must be non-empty objects or omitted** - Empty `{}` annotations cause issues; omit entirely if unused -2. **ToolingErrorCode values are exhaustive** - All error codes are defined here; do not add codes elsewhere -3. **PermissionConfig requires source field** - Must specify "headers" or "config" -4. **SessionContextConfig.allowedKeys is security-critical** - Always whitelist allowed keys +1. **Annotations must be non-empty objects or omitted** — Empty `{}` annotations cause SDK issues; omit entirely if unused +2. **ToolingErrorCode values are exhaustive** — All error codes defined here; do not add codes elsewhere +3. **PermissionConfig requires source field** — Must specify `"headers"` or `"config"` +4. **SessionContextConfig.allowedKeys is security-critical** — Always whitelist allowed keys; omitting allows arbitrary config injection ## Anti-patterns - Adding tool validation logic here (belongs in ToolRegistry) -- Importing from other src/ modules (types is leaf-level) +- Importing from other `src/` modules (types is leaf-level) - Making types mutable (all should be readonly where possible) -## Dependencies - -- Imports: None (leaf module) -- Used by: All other modules - -## See Also - -- `src/errors/ToolingError.ts` - Error class using ToolingErrorCode (18 LOC) -- `src/core/AGENTS.md` - How types are used in orchestration - --- *Keep this Intent Node updated when modifying types. See root AGENTS.md for maintenance guidelines.* From 87cf5dfa72400736d23e91f0d548cb2b16218a37 Mon Sep 17 00:00:00 2001 From: Ben Rabinovich Date: Thu, 12 Feb 2026 09:57:02 +0200 Subject: [PATCH 11/11] chore: cr fix --- src/index.ts | 3 +- src/server/createPermissionBasedMcpServer.ts | 9 ++++-- src/server/server.types.ts | 29 ++++++++++++++++++++ src/session/SessionContextResolver.ts | 7 ++++- src/types/index.ts | 29 -------------------- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index e90a225..4184339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ // Standard MCP server creation export { createMcpServer } from "./server/createMcpServer.js"; -export type { CreateMcpServerOptions, McpServerHandle } from "./server/server.types.js"; +export type { CreateMcpServerOptions, CreatePermissionBasedMcpServerOptions, McpServerHandle } from "./server/server.types.js"; // Permission-based MCP server creation (separate API for per-client toolset access control) export { createPermissionBasedMcpServer } from "./server/createPermissionBasedMcpServer.js"; @@ -16,7 +16,6 @@ export type { Mode, ModuleLoader, PermissionConfig, - CreatePermissionBasedMcpServerOptions, SessionContextConfig, SessionRequestContext, } from "./types/index.js"; diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index f70a3f2..a161eda 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -1,9 +1,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ExposurePolicy } from "../types/index.js"; import type { CreatePermissionBasedMcpServerOptions, - ExposurePolicy, -} from "../types/index.js"; -import type { McpServerHandle } from "./server.types.js"; + McpServerHandle, +} from "./server.types.js"; import { validatePermissionConfig, createPermissionAwareBundle, @@ -190,6 +190,9 @@ function buildPermissionTransport( if (options.http?.customEndpoints) { builder.customEndpoints(options.http.customEndpoints); } + if (options.configSchema) { + builder.configSchema(options.configSchema); + } return builder.build(); } diff --git a/src/server/server.types.ts b/src/server/server.types.ts index d146fca..4fec221 100644 --- a/src/server/server.types.ts +++ b/src/server/server.types.ts @@ -3,6 +3,7 @@ import type { ExposurePolicy, Mode, ModuleLoader, + PermissionConfig, SessionContextConfig, ToolSetCatalog, } from "../types/index.js"; @@ -50,3 +51,31 @@ export interface CreateMcpServerOptions { */ sessionContext?: SessionContextConfig; } + +export type CreatePermissionBasedMcpServerOptions = Omit< + CreateMcpServerOptions, + "startup" +> & { + /** + * Permission configuration defining how client access control is enforced. + * + * This field is required for permission-based servers. It determines whether + * permissions are read from request headers or resolved server-side using + * static maps or resolver functions. + * + * @see {@link PermissionConfig} for detailed configuration options and examples + */ + permissions: PermissionConfig; + + /** + * Startup configuration is not allowed for permission-based servers. + * + * Permission-based servers automatically determine which toolsets to load for + * each client based on the `permissions` configuration. The server internally + * uses STATIC mode per client to ensure isolation and prevent dynamic toolset + * changes during a session. + * + * @deprecated Do not use - permission-based servers determine toolsets from client permissions + */ + startup?: never; +}; diff --git a/src/session/SessionContextResolver.ts b/src/session/SessionContextResolver.ts index de4f630..20ef0d7 100644 --- a/src/session/SessionContextResolver.ts +++ b/src/session/SessionContextResolver.ts @@ -71,8 +71,13 @@ export class SessionContextResolver { baseContext: unknown, parsedConfig: Record ): SessionContextResult { + const resolver = this.config.contextResolver; + if (!resolver) { + return { context: baseContext, cacheKeySuffix: "default" }; + } + try { - const resolvedContext = this.config.contextResolver!( + const resolvedContext = resolver( request, baseContext, parsedConfig diff --git a/src/types/index.ts b/src/types/index.ts index 69cf0be..4aa44be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,3 @@ -import type { CreateMcpServerOptions } from "../server/server.types.js"; - // Loader concepts are internal-only; no public types for loaders export type McpToolDefinition = { @@ -385,30 +383,3 @@ export interface SessionRequestContext { query: Record; } -export type CreatePermissionBasedMcpServerOptions = Omit< - CreateMcpServerOptions, - "startup" -> & { - /** - * Permission configuration defining how client access control is enforced. - * - * This field is required for permission-based servers. It determines whether - * permissions are read from request headers or resolved server-side using - * static maps or resolver functions. - * - * @see {@link PermissionConfig} for detailed configuration options and examples - */ - permissions: PermissionConfig; - - /** - * Startup configuration is not allowed for permission-based servers. - * - * Permission-based servers automatically determine which toolsets to load for - * each client based on the `permissions` configuration. The server internally - * uses STATIC mode per client to ensure isolation and prevent dynamic toolset - * changes during a session. - * - * @deprecated Do not use - permission-based servers determine toolsets from client permissions - */ - startup?: never; -};