diff --git a/CLAUDE.md b/CLAUDE.md index 45cb132..0472bc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,38 @@ 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/CLAUDE.md` - Type definitions and contracts +- `src/core/CLAUDE.md` - ServerOrchestrator, DynamicToolManager, ToolRegistry +- `src/mode/CLAUDE.md` - ModuleResolver, validation utilities +- `src/server/CLAUDE.md` - createMcpServer, createPermissionBasedMcpServer +- `src/http/CLAUDE.md` - FastifyTransport, endpoints, SSE +- `src/session/CLAUDE.md` - SessionContextResolver, ClientResourceCache +- `src/permissions/CLAUDE.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 `CLAUDE.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 diff --git a/src/core/CLAUDE.md b/src/core/CLAUDE.md new file mode 100644 index 0000000..ecfdb29 --- /dev/null +++ b/src/core/CLAUDE.md @@ -0,0 +1,86 @@ +# Core Module + +## Purpose + +Central orchestration layer that wires together all components. 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 + +## 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): + +**DYNAMIC mode only:** +- `enable_toolset` / `disable_toolset` - Runtime toolset management +- `list_toolsets` / `describe_toolset` - Discovery + +**Both modes:** +- `list_tools` - List registered tool names + +## 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) + +## 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) +``` + +## Dependencies + +- Imports: `src/types`, `src/mode`, `src/meta` +- Used by: `src/server/*`, `src/http/*` + +## See Also + +- `src/mode/CLAUDE.md` - How tools are resolved +- `src/server/CLAUDE.md` - How orchestrator is created +- `src/types/CLAUDE.md` - ExposurePolicy, ToolingErrorCode + +--- +*Keep this Intent Node updated when modifying core orchestration. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/http/CLAUDE.md b/src/http/CLAUDE.md new file mode 100644 index 0000000..5925a9a --- /dev/null +++ b/src/http/CLAUDE.md @@ -0,0 +1,105 @@ +# HTTP Module + +## Purpose + +Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, JSON-RPC requests, and per-client server management. + +## 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 + +## Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/healthz` | Health check → `{ok: true}` | +| GET | `/tools` | Default manager status | +| GET | `/.well-known/mcp-config` | Config schema discovery | +| POST | `/mcp` | JSON-RPC requests (initialize, method calls) | +| GET | `/mcp` | SSE streaming for notifications | +| DELETE | `/mcp` | Session termination | + +## Invariants + +1. **Anonymous clients get `anon-` prefix** - Generated as `anon-${UUID}`, not cached +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 + +```typescript +// Headers normalized to lowercase +headers: Record + +// Query params filtered to string values +query: Record + +// Client ID from header or auto-generated +clientId: headers['mcp-client-id'] ?? `anon-${uuid()}` +``` + +## Session Lifecycle + +``` +1. POST /mcp (initialize) + → Create StreamableHTTPServerTransport + → 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 + → Route JSON-RPC through transport + +4. DELETE /mcp (cleanup) + → 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 + +- Registering endpoints on reserved paths (throws) +- Assuming client IDs persist (anonymous regenerated each request) +- Blocking on SSE handlers (should be async) + +## Dependencies + +- Imports: `src/types`, `src/core`, `src/session` +- Used by: `src/server/createMcpServer` + +## See Also + +- `src/session/CLAUDE.md` - SessionContextResolver, ClientResourceCache +- `src/permissions/CLAUDE.md` - PermissionAwareFastifyTransport +- `src/server/CLAUDE.md` - How transport is configured + +--- +*Keep this Intent Node updated when modifying HTTP transport. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/mode/CLAUDE.md b/src/mode/CLAUDE.md new file mode 100644 index 0000000..18ea7fb --- /dev/null +++ b/src/mode/CLAUDE.md @@ -0,0 +1,61 @@ +# Mode Module + +## Purpose + +Handles toolset resolution, validation, and startup mode determination. Provides the bridge between 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 + +## 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) + +## 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[] +``` + +## Dependencies + +- Imports: `src/types` +- Used by: `src/core/ServerOrchestrator`, `src/core/DynamicToolManager` + +## See Also + +- `src/core/CLAUDE.md` - How resolved tools are registered +- `src/types/CLAUDE.md` - ModuleLoader type definition + +--- +*Keep this Intent Node updated when modifying mode resolution. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/permissions/CLAUDE.md b/src/permissions/CLAUDE.md new file mode 100644 index 0000000..e4064a4 --- /dev/null +++ b/src/permissions/CLAUDE.md @@ -0,0 +1,91 @@ +# Permissions Module + +## Purpose + +Provides per-client access control for toolsets. Supports header-based and config-based permission resolution with caching. + +## 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 +- Anonymous clients (`anon-*`) not cached + +## 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 + +## 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 +``` + +## Caching Behavior + +**PermissionResolver cache:** +- Keyed by clientId +- NO automatic expiration +- Manual invalidation required + +**PermissionAwareFastifyTransport cache:** +- Keyed by clientId (non-anonymous only) +- 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) +- Trusting client-provided permissions without config-based fallback + +## 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 if non-anonymous) +``` + +## Dependencies + +- Imports: `src/types`, `src/core`, `src/session` +- Used by: `src/server/createPermissionBasedMcpServer` + +## See Also + +- `src/http/CLAUDE.md` - FastifyTransport base class +- `src/server/CLAUDE.md` - createPermissionBasedMcpServer +- `src/types/CLAUDE.md` - PermissionConfig type + +--- +*Keep this Intent Node updated when modifying permissions. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/server/CLAUDE.md b/src/server/CLAUDE.md new file mode 100644 index 0000000..c5ee7e4 --- /dev/null +++ b/src/server/CLAUDE.md @@ -0,0 +1,107 @@ +# Server Module + +## Purpose + +Factory functions for creating MCP servers. Provides standard and permission-based server creation with configuration validation. + +## Key Components + +**createMcpServer** (`createMcpServer.ts`) +- Standard server factory supporting DYNAMIC or STATIC modes +- Returns `{server, start(), close()}` +- Supports session context for multi-tenancy + +**createPermissionBasedMcpServer** (`createPermissionBasedMcpServer.ts`) +- Permission-controlled server factory +- Always STATIC mode per client +- No meta-tools (clients can't change toolsets) + +## 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 +} +``` + +## 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 + +## Mode Selection Logic + +``` +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) +``` + +## Server Architecture + +**Standard (createMcpServer):** +``` +DYNAMIC: Per-client ServerOrchestrator + MCP Server +STATIC: Shared ServerOrchestrator, tools pre-loaded +``` + +**Permission-based (createPermissionBasedMcpServer):** +``` +Base orchestrator (empty) for status endpoints +Per-client orchestrators loaded with allowed toolsets +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) +- Not awaiting `start()` before accepting requests + +## Dependencies + +- Imports: `src/types`, `src/core`, `src/http`, `src/session`, `src/permissions` +- Used by: Application entry points + +## See Also + +- `src/core/CLAUDE.md` - ServerOrchestrator internals +- `src/http/CLAUDE.md` - Transport layer +- `src/permissions/CLAUDE.md` - Permission resolution + +--- +*Keep this Intent Node updated when modifying server creation. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/session/CLAUDE.md b/src/session/CLAUDE.md new file mode 100644 index 0000000..8d7c5fc --- /dev/null +++ b/src/session/CLAUDE.md @@ -0,0 +1,76 @@ +# Session Module + +## Purpose + +Manages per-session context resolution and client resource caching. Enables multi-tenant scenarios where different clients/sessions need isolated contexts. + +## 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 + +## 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 + +## Cache Defaults + +- `maxSize`: 1000 entries +- `ttlMs`: 3600000 (1 hour) +- `pruneIntervalMs`: 600000 (10 minutes) + +## Anti-patterns + +- 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/CLAUDE.md` - How session context integrates with HTTP transport +- `src/types/CLAUDE.md` - SessionContextConfig type definition + +--- +*Keep this Intent Node updated when modifying session handling. See root CLAUDE.md for maintenance guidelines.* diff --git a/src/types/CLAUDE.md b/src/types/CLAUDE.md new file mode 100644 index 0000000..c201ad7 --- /dev/null +++ b/src/types/CLAUDE.md @@ -0,0 +1,62 @@ +# Types Module + +## Purpose + +Defines all TypeScript interfaces, types, and contracts for the Toolception system. This is the foundation layer with no internal dependencies. + +## Key Components + +**McpToolDefinition** - Individual tool structure with name, description, inputSchema, handler, and optional annotations. + +**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[]` + +## 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 + +## Anti-patterns + +- Adding tool validation logic here (belongs in ToolRegistry) +- 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/CLAUDE.md` - How types are used in orchestration + +--- +*Keep this Intent Node updated when modifying types. See root CLAUDE.md for maintenance guidelines.*