diff --git a/AGENTS.md b/AGENTS.md index fd8fe8c..659c3fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,8 @@ For `enable_toolset`/`disable_toolset`, read `message` to adapt decisions (e.g., - `GET /healthz` - Health check - `GET /tools` - List available toolsets and tools -- `POST /mcp` - MCP JSON-RPC requests -- `GET /mcp` - Server-sent events stream +- `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 @@ -65,3 +65,7 @@ Check `GET /tools` or server documentation to discover available custom endpoint - `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) + +### Query parameters + +- `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. diff --git a/CLAUDE.md b/CLAUDE.md index aec6c8a..45cb132 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,14 @@ Two main factory functions in `src/server/`: - 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 @@ -85,6 +93,8 @@ Two main factory functions in `src/server/`: - `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) @@ -98,8 +108,18 @@ Registered in `src/meta/registerMetaTools.ts`: Tests use Vitest with in-memory mocks. Key patterns: - Fake MCP server in `tests/helpers/fakes.ts` - Unit tests alongside integration tests in `tests/` +- E2E tests in `tests/e2e/` for full server/client flows - Smoke E2E tests in `tests/smoke-e2e/` for manual server/client testing +### Key Test Files + +- `tests/sessionContextResolver.test.ts` - Unit tests for SessionContextResolver (parsing, filtering, merging) +- `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/e2e/staticMode.e2e.test.ts` - E2E tests for STATIC mode +- `tests/e2e/permissionBased.e2e.test.ts` - E2E tests for permission-based servers + ## Build System - Vite for bundling (`vite.config.ts`) diff --git a/README.md b/README.md index f5a7c6a..49ee8b2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Permission-based starter guide](#permission-based-starter-guide) - [Permission configuration approaches](#permission-configuration-approaches) - [Custom HTTP endpoints](#custom-http-endpoints) +- [Per-session context](#per-session-context) - [API](#api) - [createMcpServer](#createmcpserveroptions) - [createPermissionBasedMcpServer](#createpermissionbasedmcpserveroptions) @@ -39,6 +40,7 @@ Toolception addresses this by grouping tools into toolsets and letting you expos - **Large or multi-domain catalogs**: You have >20–50 tools or multiple domains (e.g., search, data, billing) and don’t want to expose them all at once. - **Task-specific workflows**: You want the client/agent to enable only the tools relevant to the current task. - **Multi-tenant or policy needs**: Different users/tenants require different tool access or limits. +- **Per-session context**: You need different context values (API tokens, user IDs) for each client session passed to module loaders. - **Permission-based access control**: You need to enforce client-specific toolset permissions for security, compliance, or multi-tenant isolation. Each client should only see and access the toolsets they're authorized to use, with server-side or header-based permission enforcement. - **Collision-safe naming**: You need predictable, namespaced tool names to avoid conflicts. - **Lazy loading**: Some tools are heavy and should be loaded on demand. @@ -719,6 +721,88 @@ Custom endpoints cannot override built-in MCP paths: See `examples/custom-endpoints-demo.ts` for a full working example with GET, POST, PUT, DELETE endpoints, pagination, and permission-aware handlers. +## Per-session context + +Use the `sessionContext` option to enable per-client context values extracted from query parameters. This is useful for multi-tenant scenarios where each client needs different configuration (API tokens, user IDs, etc.) passed to module loaders. + +### Basic usage + +```ts +import { createMcpServer } from "toolception"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const { start } = await createMcpServer({ + catalog: { /* ... */ }, + moduleLoaders: { /* ... */ }, + context: { baseValue: 'shared' }, // Base context for all sessions + sessionContext: { + enabled: true, + queryParam: { + name: 'config', + encoding: 'base64', + allowedKeys: ['API_TOKEN', 'USER_ID'], // Security: always specify + }, + merge: 'shallow', + }, + createServer: () => new McpServer({ + name: "my-server", + version: "1.0.0", + capabilities: { tools: { listChanged: true } }, + }), + http: { port: 3000 }, +}); + +await start(); +``` + +### Client connection + +```bash +# Encode session config as base64 +CONFIG=$(echo -n '{"API_TOKEN":"user-secret-token","USER_ID":"123"}' | base64) + +# Connect with session config +curl -X POST "http://localhost:3000/mcp?config=$CONFIG" \ + -H "mcp-client-id: my-client" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize",...}' +``` + +### Module loader receives merged context + +```ts +const moduleLoaders = { + tenant: async (ctx: any) => { + // ctx = { baseValue: 'shared', API_TOKEN: 'user-secret-token', USER_ID: '123' } + return [/* tools using ctx.API_TOKEN */]; + }, +}; +``` + +### Custom context resolver + +For advanced use cases, provide a custom resolver function: + +```ts +sessionContext: { + enabled: true, + queryParam: { allowedKeys: ['tenant_id'] }, + contextResolver: (request, baseContext, parsedConfig) => ({ + ...baseContext, + ...parsedConfig, + clientId: request.clientId, + timestamp: Date.now(), + }), +} +``` + +### Security considerations + +- **Always specify `allowedKeys`**: Without a whitelist, any key in the query config is accepted +- **Fail-secure**: Invalid encoding silently falls back to base context +- **No logging of values**: Session config values are never logged +- **Filtered silently**: Disallowed keys are filtered without error messages + ## API ### createMcpServer(options) @@ -864,6 +948,28 @@ const moduleLoaders = { }; ``` +#### options.sessionContext (optional) + +`SessionContextConfig` + +Configuration for per-session context extraction from query parameters. Enables multi-tenant use cases where each client session can have its own context values passed to module loaders. See [Per-session context](#per-session-context) for detailed usage examples. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | `boolean` | `true` | Whether session context extraction is enabled | +| `queryParam.name` | `string` | `'config'` | Query parameter name | +| `queryParam.encoding` | `'base64' \| 'json'` | `'base64'` | Encoding format | +| `queryParam.allowedKeys` | `string[]` | - | Whitelist of allowed keys (recommended for security) | +| `contextResolver` | `function` | - | Custom context resolver function | +| `merge` | `'shallow' \| 'deep'` | `'shallow'` | How to merge with base context | + +Notes + +- Session context is extracted per-request and merged with the base `context` option +- Each unique session config generates a different cache key, enabling per-tenant module caching +- Invalid encoding or parsing errors silently fall back to base context (fail-secure) +- Only applies to DYNAMIC mode servers; STATIC mode uses a single shared server instance + #### options.http (optional) `{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; customEndpoints?: CustomEndpointDefinition[] }` @@ -970,6 +1076,14 @@ Same as `createMcpServer` - see [options.configSchema](#optionsconfigschema-opti Same as `createMcpServer` - see [options.context](#optionscontext-optional). +#### options.sessionContext (optional) + +`SessionContextConfig` + +Session context is available in permission-based servers but has limited support. Because permission-based servers determine toolsets at connection time based on permissions, the session context cannot affect which toolsets are loaded. However, the merged context is still passed to module loaders. + +**Note:** A warning is issued if `sessionContext` is used with `createPermissionBasedMcpServer`. For full session context support with per-session toolset caching, use `createMcpServer` with DYNAMIC mode. + ### Meta-tools Meta-tools are registered based on mode: diff --git a/package-lock.json b/package-lock.json index 1936a38..5a6b308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolception", - "version": "0.5.5", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolception", - "version": "0.5.5", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@fastify/cors": "^10.0.1", @@ -2879,9 +2879,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", "peer": true, "engines": { @@ -3207,9 +3207,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0ea0b37..acd7e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.5.5", + "version": "0.6.0", "private": false, "type": "module", "main": "dist/index.js", diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index cd58b56..d7ec006 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -8,6 +8,8 @@ import { randomUUID } from "node:crypto"; import type { DynamicToolManager } from "../core/DynamicToolManager.js"; import type { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { ClientResourceCache } from "../session/ClientResourceCache.js"; +import type { SessionContextResolver } from "../session/SessionContextResolver.js"; +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"; @@ -29,6 +31,15 @@ export interface FastifyTransportOptions { 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; @@ -40,10 +51,9 @@ export class FastifyTransport { customEndpoints?: CustomEndpointDefinition[]; }; private readonly defaultManager: DynamicToolManager; - private readonly createBundle: () => { - server: McpServer; - orchestrator: ServerOrchestrator; - }; + private readonly createBundle: CreateBundleCallback; + private readonly sessionContextResolver?: SessionContextResolver; + private readonly baseContext?: unknown; private app: FastifyInstance | null = null; private readonly configSchema?: object; @@ -61,12 +71,16 @@ export class FastifyTransport { constructor( defaultManager: DynamicToolManager, - createBundle: () => { server: McpServer; orchestrator: ServerOrchestrator }, + createBundle: CreateBundleCallback, options: FastifyTransportOptions = {}, - configSchema?: object + configSchema?: object, + sessionContextResolver?: SessionContextResolver, + baseContext?: unknown ) { this.defaultManager = defaultManager; this.createBundle = createBundle; + this.sessionContextResolver = sessionContextResolver; + this.baseContext = baseContext; this.options = { host: options.host ?? "0.0.0.0", port: options.port ?? 3000, @@ -125,17 +139,21 @@ export class FastifyTransport { // When anon id, avoid caching (one-off) const useCache = !clientId.startsWith("anon-"); - let bundle = useCache ? this.clientCache.get(clientId) : null; + // Build session request context and resolve merged context + const { cacheKey, mergedContext } = this.resolveSessionContext( + req, + clientId + ); + + let bundle = useCache ? this.clientCache.get(cacheKey) : null; if (!bundle) { - const created = this.createBundle(); - const providedSessions = (created as any).sessions; + const created = this.createBundle(mergedContext); bundle = { server: created.server, orchestrator: created.orchestrator, - sessions: - providedSessions instanceof Map ? providedSessions : new Map(), + sessions: new Map(), }; - if (useCache) this.clientCache.set(clientId, bundle); + if (useCache) this.clientCache.set(cacheKey, bundle); } const sessionId = req.headers["mcp-session-id"] as string | undefined; @@ -315,4 +333,91 @@ export class FastifyTransport { } bundle.sessions.clear(); } + + /** + * 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, + clientId: string + ): { cacheKey: string; mergedContext: unknown } { + // If no session context resolver, use simple clientId cache key + if (!this.sessionContextResolver) { + return { + cacheKey: clientId, + mergedContext: this.baseContext, + }; + } + + // Build session request context + const sessionRequestContext: SessionRequestContext = { + clientId, + headers: this.extractHeaders(req), + query: this.extractQuery(req), + }; + + // Resolve the merged context + const result = this.sessionContextResolver.resolve( + sessionRequestContext, + this.baseContext + ); + + // Build cache key: clientId:suffix + const cacheKey = + result.cacheKeySuffix === "default" + ? clientId + : `${clientId}:${result.cacheKeySuffix}`; + + return { + cacheKey, + mergedContext: result.context, + }; + } + + /** + * 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 = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key.toLowerCase()] = value; + } else if (Array.isArray(value) && value.length > 0) { + headers[key.toLowerCase()] = value[0]; + } + } + return headers; + } + + /** + * 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 = {}; + const rawQuery = req.query as Record; + if (rawQuery && typeof rawQuery === "object") { + for (const [key, value] of Object.entries(rawQuery)) { + if (typeof value === "string") { + query[key] = value; + } + } + } + return query; + } } diff --git a/src/index.ts b/src/index.ts index f8b7a1a..9db4886 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,8 +17,14 @@ export type { ModuleLoader, PermissionConfig, CreatePermissionBasedMcpServerOptions, + SessionContextConfig, + SessionRequestContext, } from "./types/index.js"; +// Session context support +export { SessionContextResolver } from "./session/SessionContextResolver.js"; +export type { SessionContextResult } from "./session/SessionContextResolver.js"; + // Custom endpoint support export type { CustomEndpointDefinition, diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index da3ecfb..8ac076e 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -3,6 +3,7 @@ import type { ExposurePolicy, Mode, ModuleLoader, + SessionContextConfig, ToolSetCatalog, } from "../types/index.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; @@ -10,6 +11,8 @@ import { FastifyTransport, type FastifyTransportOptions, } from "../http/FastifyTransport.js"; +import { SessionContextResolver } from "../session/SessionContextResolver.js"; +import { validateSessionContextConfig } from "../session/validateSessionContextConfig.js"; import { z } from "zod"; export interface CreateMcpServerOptions { @@ -27,6 +30,25 @@ export interface CreateMcpServerOptions { */ 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; } /** @@ -58,6 +80,21 @@ export async function createMcpServer(options: CreateMcpServerOptions) { } const mode: Exclude = options.startup?.mode ?? "DYNAMIC"; + + // Validate session context configuration if provided + let sessionContextResolver: SessionContextResolver | undefined; + if (options.sessionContext) { + validateSessionContextConfig(options.sessionContext); + sessionContextResolver = new SessionContextResolver(options.sessionContext); + + // 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." + ); + } + } if (typeof options.createServer !== "function") { throw new Error("createMcpServer: `createServer` (factory) is required"); } @@ -122,9 +159,13 @@ export async function createMcpServer(options: CreateMcpServerOptions) { 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 }; @@ -135,7 +176,7 @@ export async function createMcpServer(options: CreateMcpServerOptions) { catalog: options.catalog, moduleLoaders: options.moduleLoaders, exposurePolicy: options.exposurePolicy, - context: options.context, + context: effectiveContext, notifyToolsListChanged: async () => notifyToolsChanged(createdServer), startup: options.startup, registerMetaTools: @@ -146,7 +187,9 @@ export async function createMcpServer(options: CreateMcpServerOptions) { return { server: createdServer, orchestrator: createdOrchestrator }; }, options.http, - options.configSchema + options.configSchema, + sessionContextResolver, + options.context ); return { diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index b3e1985..b618334 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -4,6 +4,7 @@ import type { ExposurePolicy, } from "../types/index.js"; import { validatePermissionConfig } from "../permissions/validatePermissionConfig.js"; +import { validateSessionContextConfig } from "../session/validateSessionContextConfig.js"; import { PermissionResolver } from "../permissions/PermissionResolver.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { createPermissionAwareBundle } from "../permissions/createPermissionAwareBundle.js"; @@ -125,6 +126,17 @@ export async function createPermissionBasedMcpServer( // 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( diff --git a/src/session/SessionContextResolver.ts b/src/session/SessionContextResolver.ts new file mode 100644 index 0000000..aaf9270 --- /dev/null +++ b/src/session/SessionContextResolver.ts @@ -0,0 +1,299 @@ +import type { + SessionContextConfig, + SessionRequestContext, +} from "../types/index.js"; +import { createHash } from "node:crypto"; + +/** + * 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; + private readonly encoding: "base64" | "json"; + private readonly allowedKeys: Set | null; + private readonly mergeStrategy: "shallow" | "deep"; + + constructor(config: SessionContextConfig) { + this.config = config; + this.queryParamName = config.queryParam?.name ?? "config"; + this.encoding = config.queryParam?.encoding ?? "base64"; + this.allowedKeys = config.queryParam?.allowedKeys + ? new Set(config.queryParam.allowedKeys) + : null; + this.mergeStrategy = config.merge ?? "shallow"; + } + + /** + * 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 + */ + resolve( + request: SessionRequestContext, + baseContext: unknown + ): SessionContextResult { + // If disabled, return base context with default cache key + if (this.config.enabled === false) { + return { + context: baseContext, + cacheKeySuffix: "default", + }; + } + + // 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", + }; + } + } + + // Default merge behavior + const mergedContext = this.mergeContexts(baseContext, parsedConfig); + return { + context: mergedContext, + cacheKeySuffix: this.generateCacheKeySuffix(parsedConfig), + }; + } + + /** + * 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 + ): Record { + const rawValue = query[this.queryParamName]; + if (!rawValue) { + return {}; + } + + try { + let jsonString: string; + + if (this.encoding === "base64") { + // Decode base64 to JSON string + jsonString = Buffer.from(rawValue, "base64").toString("utf-8"); + } else { + // JSON encoding - value should already be JSON string + jsonString = rawValue; + } + + const parsed = JSON.parse(jsonString); + + // Must be an object + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {}; + } + + // Filter allowed keys if whitelist is configured + return this.filterAllowedKeys(parsed); + } catch { + // Fail secure: return empty object on any parse error + return {}; + } + } + + /** + * 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 + ): Record { + if (!this.allowedKeys) { + return parsed; + } + + const filtered: Record = {}; + for (const key of this.allowedKeys) { + if (key in parsed) { + filtered[key] = parsed[key]; + } + } + return filtered; + } + + /** + * 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, + sessionConfig: Record + ): unknown { + // If no session config, return base context as-is + if (Object.keys(sessionConfig).length === 0) { + return baseContext; + } + + // If base context is not an object, session config takes precedence + if ( + typeof baseContext !== "object" || + baseContext === null || + Array.isArray(baseContext) + ) { + return sessionConfig; + } + + if (this.mergeStrategy === "deep") { + return this.deepMerge( + baseContext as Record, + sessionConfig + ); + } + + // Shallow merge: session config overrides base context + return { + ...(baseContext as Record), + ...sessionConfig, + }; + } + + /** + * 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, + override: Record + ): Record { + const result: Record = { ...base }; + + for (const [key, value] of Object.entries(override)) { + const baseValue = result[key]; + + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + // Both are objects - deep merge + result[key] = this.deepMerge( + baseValue as Record, + value as Record + ); + } else { + // Override base value + result[key] = value; + } + } + + return result; + } + + /** + * 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 + ): string { + if (Object.keys(sessionConfig).length === 0) { + return "default"; + } + + // Sort keys for deterministic hash + const sortedKeys = Object.keys(sessionConfig).sort(); + const normalizedObj: Record = {}; + for (const key of sortedKeys) { + normalizedObj[key] = sessionConfig[key]; + } + + const jsonString = JSON.stringify(normalizedObj); + return createHash("sha256").update(jsonString).digest("hex").slice(0, 16); + } +} diff --git a/src/session/validateSessionContextConfig.ts b/src/session/validateSessionContextConfig.ts new file mode 100644 index 0000000..357de05 --- /dev/null +++ b/src/session/validateSessionContextConfig.ts @@ -0,0 +1,143 @@ +import type { SessionContextConfig } from "../types/index.js"; + +/** + * Validates a session context configuration object to ensure it meets all requirements. + * 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); + validateEnabledField(config); + validateQueryParamConfig(config); + validateContextResolver(config); + validateMergeStrategy(config); +} + +/** + * 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") { + throw new Error( + "Session context configuration must be an object" + ); + } +} + +/** + * 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) { + return; + } + + if (typeof config.enabled !== "boolean") { + throw new Error( + `enabled must be a boolean, got ${typeof config.enabled}` + ); + } +} + +/** + * 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) { + return; + } + + if (typeof config.queryParam !== "object" || config.queryParam === null) { + throw new Error("queryParam must be an object"); + } + + // Validate name + if (config.queryParam.name !== undefined) { + if ( + typeof config.queryParam.name !== "string" || + config.queryParam.name.length === 0 + ) { + throw new Error("queryParam.name must be a non-empty string"); + } + } + + // Validate encoding + if (config.queryParam.encoding !== undefined) { + if ( + config.queryParam.encoding !== "base64" && + config.queryParam.encoding !== "json" + ) { + throw new Error( + `Invalid queryParam.encoding: "${config.queryParam.encoding}". Must be "base64" or "json"` + ); + } + } + + // Validate allowedKeys + if (config.queryParam.allowedKeys !== undefined) { + if (!Array.isArray(config.queryParam.allowedKeys)) { + throw new Error("queryParam.allowedKeys must be an array of strings"); + } + + for (let i = 0; i < config.queryParam.allowedKeys.length; i++) { + const key = config.queryParam.allowedKeys[i]; + if (typeof key !== "string" || key.length === 0) { + throw new Error( + `queryParam.allowedKeys[${i}] must be a non-empty string` + ); + } + } + } +} + +/** + * 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) { + return; + } + + if (typeof config.contextResolver !== "function") { + throw new Error( + "contextResolver must be a function: (request, baseContext, parsedQueryConfig?) => unknown" + ); + } +} + +/** + * 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) { + return; + } + + if (config.merge !== "shallow" && config.merge !== "deep") { + throw new Error( + `Invalid merge strategy: "${config.merge}". Must be "shallow" or "deep"` + ); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 5ba43ad..9322112 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -270,6 +270,122 @@ export type PermissionConfig = { * }; * ``` */ +/** + * Configuration for per-session context that can be extracted from query parameters + * and merged with the base context on a per-request basis. + * + * This enables multi-tenant use cases where each client session can have its own + * context values (e.g., API tokens, user IDs) that are passed to module loaders. + * + * @example Basic usage with query parameter + * ```typescript + * const options: SessionContextConfig = { + * enabled: true, + * queryParam: { + * name: 'config', // default + * encoding: 'base64', // default + * allowedKeys: ['API_TOKEN', 'USER_ID'], // whitelist (recommended) + * }, + * merge: 'shallow', // default + * }; + * // Client sends: ?config=BASE64_ENCODED_JSON + * ``` + * + * @example With custom context resolver + * ```typescript + * const options: SessionContextConfig = { + * enabled: true, + * queryParam: { + * allowedKeys: ['tenant_id'], + * }, + * contextResolver: (request, baseContext, parsedConfig) => { + * // Custom logic to build final context + * return { + * ...baseContext, + * ...parsedConfig, + * resolvedAt: Date.now(), + * }; + * }, + * }; + * ``` + */ +export type SessionContextConfig = { + /** + * Whether session context extraction is enabled. + * @default true when SessionContextConfig is provided + */ + enabled?: boolean; + + /** + * Configuration for extracting context from query parameters. + */ + queryParam?: { + /** + * Name of the query parameter containing the session config. + * @default 'config' + */ + name?: string; + + /** + * Encoding format of the query parameter value. + * - 'base64': Value is base64-encoded JSON (recommended for URLs) + * - 'json': Value is URL-encoded JSON + * @default 'base64' + */ + encoding?: "base64" | "json"; + + /** + * Whitelist of allowed keys from the parsed config. + * Only these keys will be extracted and merged into context. + * Strongly recommended for security to prevent injection of arbitrary context values. + */ + allowedKeys?: string[]; + }; + + /** + * Optional custom function to resolve the final context. + * Called after query parameter parsing and key filtering. + * + * @param request - The request context (clientId, headers, query) + * @param baseContext - The base context from server configuration + * @param parsedQueryConfig - The parsed and filtered query config (if any) + * @returns The final merged context to pass to module loaders + */ + contextResolver?: ( + request: SessionRequestContext, + baseContext: unknown, + parsedQueryConfig?: Record + ) => unknown; + + /** + * How to merge session context with base context. + * - 'shallow': Object.assign style merge (session overrides base) + * - 'deep': Deep merge of nested objects + * @default 'shallow' + */ + merge?: "shallow" | "deep"; +}; + +/** + * Context information about the incoming request, available to context resolvers. + */ +export interface SessionRequestContext { + /** + * The client identifier (from mcp-client-id header or auto-generated). + */ + clientId: string; + + /** + * HTTP headers from the request (lowercased keys). + */ + headers: Record; + + /** + * Query parameters from the request URL. + */ + query: Record; +} + export type CreatePermissionBasedMcpServerOptions = Omit< CreateMcpServerOptions, "startup" diff --git a/tests/e2e/dynamicMode.e2e.test.ts b/tests/e2e/dynamicMode.e2e.test.ts index a7dace6..b0abd62 100644 --- a/tests/e2e/dynamicMode.e2e.test.ts +++ b/tests/e2e/dynamicMode.e2e.test.ts @@ -9,6 +9,8 @@ import { extractToolNames, extractTextContent, parseToolResponse, + sessionContextCatalog, + sessionContextModuleLoaders, } from "./helpers.js"; describe("DYNAMIC mode E2E", () => { @@ -280,3 +282,263 @@ describe("DYNAMIC mode E2E", () => { await client2.close(); }); }); + +describe("DYNAMIC mode with session context E2E", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + server = await createMcpServer({ + createServer: () => + new McpServer( + { + name: "test-session-context", + version: "1.0.0", + }, + { capabilities: { tools: { listChanged: true } } } + ), + catalog: sessionContextCatalog, + moduleLoaders: sessionContextModuleLoaders, + context: { baseValue: "shared" }, + sessionContext: { + enabled: true, + queryParam: { + name: "config", + encoding: "base64", + allowedKeys: ["API_TOKEN", "USER_ID"], + }, + merge: "shallow", + }, + startup: { mode: "DYNAMIC" }, + http: { port }, + }); + await server.start(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("passes session context to module loaders via query param", async () => { + const sessionConfig = { API_TOKEN: "secret-token-123", USER_ID: "user-42" }; + const configBase64 = Buffer.from(JSON.stringify(sessionConfig)).toString( + "base64" + ); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp?config=${configBase64}`), + { requestInit: { headers: { "mcp-client-id": "session-client-1" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + // Enable the tenant toolset + await client.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + + // Call get_context to verify context was passed + const result = await client.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + + const context = parseToolResponse<{ + API_TOKEN: string; + USER_ID: string; + baseValue: string; + }>(result); + + expect(context.API_TOKEN).toBe("secret-token-123"); + expect(context.USER_ID).toBe("user-42"); + expect(context.baseValue).toBe("shared"); + + await client.close(); + }); + + it("merges session context with base context", async () => { + const sessionConfig = { API_TOKEN: "another-token" }; + const configBase64 = Buffer.from(JSON.stringify(sessionConfig)).toString( + "base64" + ); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp?config=${configBase64}`), + { requestInit: { headers: { "mcp-client-id": "session-client-2" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + await client.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + + const result = await client.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + + const context = parseToolResponse<{ + API_TOKEN: string; + USER_ID: string | null; + baseValue: string; + }>(result); + + // Session config provides API_TOKEN + expect(context.API_TOKEN).toBe("another-token"); + // USER_ID not in session config, should be null + expect(context.USER_ID).toBeNull(); + // Base context is preserved + expect(context.baseValue).toBe("shared"); + + await client.close(); + }); + + it("filters disallowed keys from session config", async () => { + const sessionConfig = { + API_TOKEN: "valid-token", + FORBIDDEN_KEY: "should-not-appear", + USER_ID: "user-99", + }; + const configBase64 = Buffer.from(JSON.stringify(sessionConfig)).toString( + "base64" + ); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp?config=${configBase64}`), + { requestInit: { headers: { "mcp-client-id": "session-client-3" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + await client.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + + const result = await client.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + + const context = parseToolResponse<{ + API_TOKEN: string; + USER_ID: string; + baseValue: string; + }>(result); + + // Allowed keys are present + expect(context.API_TOKEN).toBe("valid-token"); + expect(context.USER_ID).toBe("user-99"); + // Base context is preserved + expect(context.baseValue).toBe("shared"); + // FORBIDDEN_KEY is not in the response (filtered by allowedKeys) + + await client.close(); + }); + + it("uses base context when no session config provided", async () => { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers: { "mcp-client-id": "session-client-4" } } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + + await client.connect(transport); + + await client.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + + const result = await client.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + + const context = parseToolResponse<{ + API_TOKEN: string | null; + USER_ID: string | null; + baseValue: string; + }>(result); + + // No session config, so API_TOKEN and USER_ID are null + expect(context.API_TOKEN).toBeNull(); + expect(context.USER_ID).toBeNull(); + // Base context is still available + expect(context.baseValue).toBe("shared"); + + await client.close(); + }); + + it("different clients with different session configs get isolated contexts", async () => { + // Client A with token A + const configA = { API_TOKEN: "token-A", USER_ID: "user-A" }; + const configBase64A = Buffer.from(JSON.stringify(configA)).toString( + "base64" + ); + const transportA = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp?config=${configBase64A}`), + { requestInit: { headers: { "mcp-client-id": "isolated-client-A" } } } + ); + const clientA = new Client({ name: "test", version: "1.0.0" }); + await clientA.connect(transportA); + + // Client B with token B + const configB = { API_TOKEN: "token-B", USER_ID: "user-B" }; + const configBase64B = Buffer.from(JSON.stringify(configB)).toString( + "base64" + ); + const transportB = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp?config=${configBase64B}`), + { requestInit: { headers: { "mcp-client-id": "isolated-client-B" } } } + ); + const clientB = new Client({ name: "test", version: "1.0.0" }); + await clientB.connect(transportB); + + // Enable toolset for both + await clientA.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + await clientB.callTool({ + name: "enable_toolset", + arguments: { name: "tenant" }, + } as any); + + // Get context for client A + const resultA = await clientA.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + const contextA = parseToolResponse<{ + API_TOKEN: string; + USER_ID: string; + }>(resultA); + + // Get context for client B + const resultB = await clientB.callTool({ + name: "tenant.get_context", + arguments: {}, + } as any); + const contextB = parseToolResponse<{ + API_TOKEN: string; + USER_ID: string; + }>(resultB); + + // Each client should have their own context + expect(contextA.API_TOKEN).toBe("token-A"); + expect(contextA.USER_ID).toBe("user-A"); + expect(contextB.API_TOKEN).toBe("token-B"); + expect(contextB.USER_ID).toBe("user-B"); + + await clientA.close(); + await clientB.close(); + }); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 30d73ec..7e415a7 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -76,3 +76,39 @@ export function parseToolResponse(callToolResponse: any): T { const text = extractTextContent(callToolResponse); return JSON.parse(text) as T; } + +/** + * Catalog for session context tests - uses module loaders to access context + */ +export const sessionContextCatalog: ToolSetCatalog = { + tenant: { + name: "Tenant Tools", + description: "Tools that use session context", + modules: ["tenant"], + }, +}; + +/** + * Module loaders for session context tests - captures context values + */ +export const sessionContextModuleLoaders = { + tenant: async (ctx: any) => [ + { + name: "get_context", + description: "Returns the current context values", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + API_TOKEN: ctx?.API_TOKEN ?? null, + USER_ID: ctx?.USER_ID ?? null, + baseValue: ctx?.baseValue ?? null, + }), + }, + ], + }), + }, + ], +}; diff --git a/tests/fastifyTransport.test.ts b/tests/fastifyTransport.test.ts index 6cfa669..f2305ae 100644 --- a/tests/fastifyTransport.test.ts +++ b/tests/fastifyTransport.test.ts @@ -42,12 +42,9 @@ describe("FastifyTransport", () => { await transport.stop(); }); - it("DELETE /mcp evicts session after close", async () => { - // Fake server that supports connect() + it("DELETE /mcp returns proper errors for invalid requests", async () => { const server: any = { - async connect(_t: any) { - // no-op - }, + async connect(_t: any) {}, }; const resolver = new ModuleResolver({ catalog: { core: { name: "Core", description: "", tools: [] } } as any, @@ -55,46 +52,40 @@ describe("FastifyTransport", () => { const manager = new DynamicToolManager({ server, resolver }); const app = Fastify({ logger: false }); - // Stub createBundle with a minimal streamable transport-like object - const sessions = new Map(); - const bundle = { server, orchestrator: {} as any, sessions } as any; const transport = new FastifyTransport( manager, - () => bundle, + () => ({ server, orchestrator: {} as any }), { port: 0, logger: false, app } ); await transport.start(); - const clientId = "c1"; - // Seed bundle in cache with a non-initialize POST (will 400 but caches bundle) - await app.inject({ - method: "POST", + // Missing mcp-client-id header + const res1 = await app.inject({ + method: "DELETE", url: "/mcp", - headers: { "mcp-client-id": clientId }, - payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + headers: { "mcp-session-id": "some-session" }, }); + expect(res1.statusCode).toBe(400); + expect(res1.json().error.message).toContain("Missing mcp-client-id"); - // Now create a fake session inside the cached bundle - const createdSessionId = "s-1"; - const storedTransport: any = { - sessionId: createdSessionId, - async handleRequest() {}, - async close() { - this._closed = true; - }, - }; - sessions.set(createdSessionId, storedTransport); + // Missing mcp-session-id header + const res2 = await app.inject({ + method: "DELETE", + url: "/mcp", + headers: { "mcp-client-id": "some-client" }, + }); + expect(res2.statusCode).toBe(400); + expect(res2.json().error.message).toContain("Missing"); - // Attempt DELETE - const res = await app.inject({ + // Non-existent client/session returns 404 + const res3 = await app.inject({ method: "DELETE", url: "/mcp", - headers: { "mcp-client-id": clientId, "mcp-session-id": createdSessionId }, + headers: { "mcp-client-id": "unknown-client", "mcp-session-id": "unknown-session" }, }); - expect(res.statusCode).toBe(204); - expect(storedTransport._closed).toBe(true); - expect(sessions.has(createdSessionId)).toBe(false); + expect(res3.statusCode).toBe(404); + expect(res3.json().error.message).toContain("not found"); await transport.stop(); }); diff --git a/tests/sessionContext.integration.test.ts b/tests/sessionContext.integration.test.ts new file mode 100644 index 0000000..a6f381d --- /dev/null +++ b/tests/sessionContext.integration.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi } from "vitest"; +import Fastify from "fastify"; +import { FastifyTransport } from "../src/http/FastifyTransport.js"; +import { SessionContextResolver } from "../src/session/SessionContextResolver.js"; +import { DynamicToolManager } from "../src/core/DynamicToolManager.js"; +import { ModuleResolver } from "../src/mode/ModuleResolver.js"; +import { createFakeMcpServer } from "./helpers/fakes.js"; +import type { SessionContextConfig, ToolSetCatalog } from "../src/types/index.js"; + +describe("Session Context Integration", () => { + const createCatalog = (): ToolSetCatalog => ({ + core: { name: "Core", description: "Core tools", tools: [] }, + }); + + describe("FastifyTransport with SessionContextResolver", () => { + it("passes merged context to createBundle callback", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["API_TOKEN"], + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + + const baseContext = { baseValue: "original" }; + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + baseContext + ); + await transport.start(); + + // Make request with session config + const configJson = JSON.stringify({ API_TOKEN: "secret-token" }); + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(configJson)}`, + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + // Verify createBundle was called with merged context + expect(createBundleSpy).toHaveBeenCalledWith({ + baseValue: "original", + API_TOKEN: "secret-token", + }); + + await transport.stop(); + }); + + it("uses base64 encoding by default", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + allowedKeys: ["USER_ID"], + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + {} + ); + await transport.start(); + + // Make request with base64-encoded config + const configBase64 = Buffer.from('{"USER_ID":"12345"}').toString("base64"); + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(configBase64)}`, + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + expect(createBundleSpy).toHaveBeenCalledWith({ USER_ID: "12345" }); + + await transport.stop(); + }); + + it("filters disallowed keys silently", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["allowed"], + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + { base: true } + ); + await transport.start(); + + const configJson = JSON.stringify({ + allowed: "yes", + forbidden: "should not appear", + another_forbidden: "also filtered", + }); + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(configJson)}`, + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + expect(createBundleSpy).toHaveBeenCalledWith({ + base: true, + allowed: "yes", + }); + + await transport.stop(); + }); + + it("uses different cache keys for different session configs", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + + let callCount = 0; + const createBundleSpy = vi.fn(() => { + callCount++; + return { + server, + orchestrator: {} as any, + }; + }); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + {} + ); + await transport.start(); + + const clientId = "same-client"; + + // First request with config A + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(JSON.stringify({ tenant: "A" }))}`, + headers: { "mcp-client-id": clientId }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + expect(callCount).toBe(1); + + // Second request with same config A - should use cache + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(JSON.stringify({ tenant: "A" }))}`, + headers: { "mcp-client-id": clientId }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + expect(callCount).toBe(1); // Still 1, cached + + // Third request with config B - should create new bundle + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(JSON.stringify({ tenant: "B" }))}`, + headers: { "mcp-client-id": clientId }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + expect(callCount).toBe(2); // New bundle created + + await transport.stop(); + }); + + it("passes base context when no session config provided", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + const baseContext = { defaultKey: "defaultValue" }; + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + baseContext + ); + await transport.start(); + + // Request without config query param + await app.inject({ + method: "POST", + url: "/mcp", + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + expect(createBundleSpy).toHaveBeenCalledWith({ defaultKey: "defaultValue" }); + + await transport.stop(); + }); + + it("falls back to base context on invalid JSON config", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + const baseContext = { fallback: true }; + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + baseContext + ); + await transport.start(); + + // Request with invalid JSON + await app.inject({ + method: "POST", + url: "/mcp?config=not-valid-json", + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + expect(createBundleSpy).toHaveBeenCalledWith({ fallback: true }); + + await transport.stop(); + }); + + it("works without session context resolver (backward compatibility)", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app } + ); + await transport.start(); + + await app.inject({ + method: "POST", + url: "/mcp?config=ignored", + headers: { "mcp-client-id": "test-client" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + // Without resolver, createBundle is called with undefined context + expect(createBundleSpy).toHaveBeenCalledWith(undefined); + + await transport.stop(); + }); + }); + + describe("custom context resolver", () => { + it("uses custom resolver to build context", async () => { + const { server } = createFakeMcpServer(); + const catalog = createCatalog(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + + const sessionConfig: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["tenant"], + }, + contextResolver: (request, baseContext, parsedConfig) => ({ + ...(baseContext as object), + ...parsedConfig, + clientId: request.clientId, + timestamp: "fixed-for-test", + }), + }; + const sessionContextResolver = new SessionContextResolver(sessionConfig); + + const createBundleSpy = vi.fn(() => ({ + server, + orchestrator: {} as any, + })); + + const app = Fastify({ logger: false }); + const transport = new FastifyTransport( + manager, + createBundleSpy, + { port: 0, logger: false, app }, + undefined, + sessionContextResolver, + { base: "value" } + ); + await transport.start(); + + await app.inject({ + method: "POST", + url: `/mcp?config=${encodeURIComponent(JSON.stringify({ tenant: "acme" }))}`, + headers: { "mcp-client-id": "my-client-id" }, + payload: { jsonrpc: "2.0", id: 1, method: "unknown", params: {} }, + }); + + expect(createBundleSpy).toHaveBeenCalledWith({ + base: "value", + tenant: "acme", + clientId: "my-client-id", + timestamp: "fixed-for-test", + }); + + await transport.stop(); + }); + }); +}); diff --git a/tests/sessionContextResolver.test.ts b/tests/sessionContextResolver.test.ts new file mode 100644 index 0000000..99116ac --- /dev/null +++ b/tests/sessionContextResolver.test.ts @@ -0,0 +1,641 @@ +import { describe, it, expect, vi } from "vitest"; +import { SessionContextResolver } from "../src/session/SessionContextResolver.js"; +import type { + SessionContextConfig, + SessionRequestContext, +} from "../src/types/index.js"; + +describe("SessionContextResolver", () => { + const createRequest = ( + overrides: Partial = {} + ): SessionRequestContext => ({ + clientId: "test-client", + headers: {}, + query: {}, + ...overrides, + }); + + describe("disabled mode", () => { + it("returns base context when explicitly disabled", () => { + const config: SessionContextConfig = { + enabled: false, + }; + const resolver = new SessionContextResolver(config); + const baseContext = { foo: "bar" }; + + const result = resolver.resolve(createRequest(), baseContext); + + expect(result.context).toEqual({ foo: "bar" }); + expect(result.cacheKeySuffix).toBe("default"); + }); + }); + + describe("query parameter parsing", () => { + describe("base64 encoding (default)", () => { + it("parses base64-encoded JSON from default config param", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + allowedKeys: ["API_TOKEN"], + }, + }; + const resolver = new SessionContextResolver(config); + // {"API_TOKEN":"secret"} base64 encoded + const encoded = Buffer.from('{"API_TOKEN":"secret"}').toString( + "base64" + ); + + const result = resolver.resolve( + createRequest({ query: { config: encoded } }), + {} + ); + + expect(result.context).toEqual({ API_TOKEN: "secret" }); + }); + + it("uses custom query param name", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + name: "session", + allowedKeys: ["user_id"], + }, + }; + const resolver = new SessionContextResolver(config); + const encoded = Buffer.from('{"user_id":"123"}').toString("base64"); + + const result = resolver.resolve( + createRequest({ query: { session: encoded } }), + {} + ); + + expect(result.context).toEqual({ user_id: "123" }); + }); + + it("returns empty config for invalid base64", () => { + const config: SessionContextConfig = { + enabled: true, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: "not-valid-base64!!!" } }), + { base: "value" } + ); + + expect(result.context).toEqual({ base: "value" }); + expect(result.cacheKeySuffix).toBe("default"); + }); + + it("returns empty config for invalid JSON after base64 decode", () => { + const config: SessionContextConfig = { + enabled: true, + }; + const resolver = new SessionContextResolver(config); + const encoded = Buffer.from("not json").toString("base64"); + + const result = resolver.resolve( + createRequest({ query: { config: encoded } }), + { base: "value" } + ); + + expect(result.context).toEqual({ base: "value" }); + }); + }); + + describe("json encoding", () => { + it("parses JSON-encoded config param", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["token"], + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: '{"token":"abc"}' } }), + {} + ); + + expect(result.context).toEqual({ token: "abc" }); + }); + + it("returns empty config for invalid JSON", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: "not json" } }), + { base: true } + ); + + expect(result.context).toEqual({ base: true }); + }); + }); + + describe("missing query param", () => { + it("returns base context when query param is missing", () => { + const config: SessionContextConfig = { + enabled: true, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve(createRequest(), { base: "context" }); + + expect(result.context).toEqual({ base: "context" }); + expect(result.cacheKeySuffix).toBe("default"); + }); + }); + + describe("non-object values", () => { + it("returns empty config for array JSON", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: "[1,2,3]" } }), + { base: true } + ); + + expect(result.context).toEqual({ base: true }); + }); + + it("returns empty config for primitive JSON", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: '"string"' } }), + { base: true } + ); + + expect(result.context).toEqual({ base: true }); + }); + + it("returns empty config for null JSON", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ query: { config: "null" } }), + { base: true } + ); + + expect(result.context).toEqual({ base: true }); + }); + }); + }); + + describe("allowed keys filtering", () => { + it("filters to only allowed keys", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["allowed1", "allowed2"], + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { + config: JSON.stringify({ + allowed1: "yes", + allowed2: "also yes", + forbidden: "no", + }), + }, + }), + {} + ); + + expect(result.context).toEqual({ allowed1: "yes", allowed2: "also yes" }); + expect(result.context).not.toHaveProperty("forbidden"); + }); + + it("passes through all keys when no allowedKeys specified", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ any: "key", goes: "through" }) }, + }), + {} + ); + + expect(result.context).toEqual({ any: "key", goes: "through" }); + }); + + it("returns empty object when no allowed keys match", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["nonexistent"], + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ other: "value" }) }, + }), + { base: "context" } + ); + + expect(result.context).toEqual({ base: "context" }); + }); + }); + + describe("context merging", () => { + describe("shallow merge (default)", () => { + it("merges session config over base context", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ session: "value" }) }, + }), + { base: "value", override: "original" } + ); + + expect(result.context).toEqual({ + base: "value", + override: "original", + session: "value", + }); + }); + + it("session config overrides base context values", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + merge: "shallow", + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ shared: "from session" }) }, + }), + { shared: "from base", other: "value" } + ); + + expect(result.context).toEqual({ + shared: "from session", + other: "value", + }); + }); + + it("handles non-object base context", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value" }) }, + }), + "not an object" + ); + + expect(result.context).toEqual({ key: "value" }); + }); + + it("handles null base context", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value" }) }, + }), + null + ); + + expect(result.context).toEqual({ key: "value" }); + }); + + it("handles array base context", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value" }) }, + }), + [1, 2, 3] + ); + + expect(result.context).toEqual({ key: "value" }); + }); + }); + + describe("deep merge", () => { + it("deep merges nested objects", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + merge: "deep", + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { + config: JSON.stringify({ + nested: { sessionKey: "sessionValue" }, + }), + }, + }), + { nested: { baseKey: "baseValue" }, top: "level" } + ); + + expect(result.context).toEqual({ + nested: { baseKey: "baseValue", sessionKey: "sessionValue" }, + top: "level", + }); + }); + + it("session values override base values in deep merge", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + merge: "deep", + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { + config: JSON.stringify({ nested: { shared: "from session" } }), + }, + }), + { nested: { shared: "from base", other: "value" } } + ); + + expect(result.context).toEqual({ + nested: { shared: "from session", other: "value" }, + }); + }); + + it("handles arrays in deep merge (override, not merge)", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + merge: "deep", + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { + config: JSON.stringify({ arr: [4, 5, 6] }), + }, + }), + { arr: [1, 2, 3] } + ); + + expect(result.context).toEqual({ arr: [4, 5, 6] }); + }); + }); + }); + + describe("custom context resolver", () => { + it("uses custom resolver function", () => { + const customResolver = vi.fn( + (request, baseContext, parsedConfig) => ({ + ...baseContext, + ...parsedConfig, + custom: "added", + clientId: request.clientId, + }) + ); + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + contextResolver: customResolver, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + clientId: "my-client", + query: { config: JSON.stringify({ parsed: "value" }) }, + }), + { base: "context" } + ); + + expect(customResolver).toHaveBeenCalled(); + expect(result.context).toEqual({ + base: "context", + parsed: "value", + custom: "added", + clientId: "my-client", + }); + }); + + it("falls back to base context when resolver throws", () => { + const config: SessionContextConfig = { + enabled: true, + contextResolver: () => { + throw new Error("Resolver failed"); + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve(createRequest(), { base: "context" }); + + expect(result.context).toEqual({ base: "context" }); + expect(result.cacheKeySuffix).toBe("default"); + }); + + it("receives parsed and filtered config", () => { + const customResolver = vi.fn( + (_request, baseContext, parsedConfig) => parsedConfig + ); + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + allowedKeys: ["allowed"], + }, + contextResolver: customResolver, + }; + const resolver = new SessionContextResolver(config); + + resolver.resolve( + createRequest({ + query: { + config: JSON.stringify({ allowed: "yes", forbidden: "no" }), + }, + }), + {} + ); + + expect(customResolver).toHaveBeenCalledWith( + expect.any(Object), + {}, + { allowed: "yes" } + ); + }); + }); + + describe("cache key generation", () => { + it("returns 'default' when no session config", () => { + const config: SessionContextConfig = { + enabled: true, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve(createRequest(), { base: true }); + + expect(result.cacheKeySuffix).toBe("default"); + }); + + it("generates hash for session config", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value" }) }, + }), + {} + ); + + expect(result.cacheKeySuffix).not.toBe("default"); + expect(result.cacheKeySuffix).toHaveLength(16); + }); + + it("generates consistent hash for same config", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + const query = { config: JSON.stringify({ key: "value" }) }; + + const result1 = resolver.resolve(createRequest({ query }), {}); + const result2 = resolver.resolve(createRequest({ query }), {}); + + expect(result1.cacheKeySuffix).toBe(result2.cacheKeySuffix); + }); + + it("generates same hash regardless of key order", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result1 = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ a: 1, b: 2 }) }, + }), + {} + ); + const result2 = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ b: 2, a: 1 }) }, + }), + {} + ); + + expect(result1.cacheKeySuffix).toBe(result2.cacheKeySuffix); + }); + + it("generates different hash for different config values", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + encoding: "json", + }, + }; + const resolver = new SessionContextResolver(config); + + const result1 = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value1" }) }, + }), + {} + ); + const result2 = resolver.resolve( + createRequest({ + query: { config: JSON.stringify({ key: "value2" }) }, + }), + {} + ); + + expect(result1.cacheKeySuffix).not.toBe(result2.cacheKeySuffix); + }); + }); +}); diff --git a/tests/validateSessionContextConfig.test.ts b/tests/validateSessionContextConfig.test.ts new file mode 100644 index 0000000..353db35 --- /dev/null +++ b/tests/validateSessionContextConfig.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from "vitest"; +import { validateSessionContextConfig } from "../src/session/validateSessionContextConfig.js"; +import type { SessionContextConfig } from "../src/types/index.js"; + +describe("validateSessionContextConfig", () => { + describe("valid configurations", () => { + it("accepts minimal configuration", () => { + const config: SessionContextConfig = {}; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts enabled: true", () => { + const config: SessionContextConfig = { + enabled: true, + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts enabled: false", () => { + const config: SessionContextConfig = { + enabled: false, + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts full queryParam configuration", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + name: "config", + encoding: "base64", + allowedKeys: ["key1", "key2"], + }, + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts json encoding", () => { + const config: SessionContextConfig = { + queryParam: { + encoding: "json", + }, + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts contextResolver function", () => { + const config: SessionContextConfig = { + contextResolver: () => ({}), + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts shallow merge", () => { + const config: SessionContextConfig = { + merge: "shallow", + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts deep merge", () => { + const config: SessionContextConfig = { + merge: "deep", + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + + it("accepts complete configuration", () => { + const config: SessionContextConfig = { + enabled: true, + queryParam: { + name: "session", + encoding: "json", + allowedKeys: ["API_TOKEN", "USER_ID"], + }, + contextResolver: (req, base, parsed) => ({ + ...base, + ...parsed, + }), + merge: "deep", + }; + expect(() => validateSessionContextConfig(config)).not.toThrow(); + }); + }); + + describe("invalid config object", () => { + it("throws when config is null", () => { + expect(() => validateSessionContextConfig(null as any)).toThrow( + "Session context configuration must be an object" + ); + }); + + it("throws when config is undefined", () => { + expect(() => validateSessionContextConfig(undefined as any)).toThrow( + "Session context configuration must be an object" + ); + }); + + it("throws when config is not an object", () => { + expect(() => validateSessionContextConfig("invalid" as any)).toThrow( + "Session context configuration must be an object" + ); + }); + + it("throws when config is a number", () => { + expect(() => validateSessionContextConfig(123 as any)).toThrow( + "Session context configuration must be an object" + ); + }); + }); + + describe("invalid enabled field", () => { + it("throws when enabled is a string", () => { + const config = { + enabled: "true", + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "enabled must be a boolean" + ); + }); + + it("throws when enabled is a number", () => { + const config = { + enabled: 1, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "enabled must be a boolean" + ); + }); + + it("throws when enabled is null", () => { + const config = { + enabled: null, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "enabled must be a boolean" + ); + }); + }); + + describe("invalid queryParam configuration", () => { + it("throws when queryParam is not an object", () => { + const config = { + queryParam: "invalid", + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam must be an object" + ); + }); + + it("throws when queryParam is null", () => { + const config = { + queryParam: null, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam must be an object" + ); + }); + + it("throws when queryParam.name is empty string", () => { + const config: SessionContextConfig = { + queryParam: { + name: "", + }, + }; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam.name must be a non-empty string" + ); + }); + + it("throws when queryParam.name is not a string", () => { + const config = { + queryParam: { + name: 123, + }, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam.name must be a non-empty string" + ); + }); + + it("throws when queryParam.encoding is invalid", () => { + const config = { + queryParam: { + encoding: "invalid", + }, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + 'Invalid queryParam.encoding: "invalid". Must be "base64" or "json"' + ); + }); + + it("throws when queryParam.allowedKeys is not an array", () => { + const config = { + queryParam: { + allowedKeys: "not-an-array", + }, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam.allowedKeys must be an array of strings" + ); + }); + + it("throws when queryParam.allowedKeys contains non-string", () => { + const config = { + queryParam: { + allowedKeys: ["valid", 123], + }, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam.allowedKeys[1] must be a non-empty string" + ); + }); + + it("throws when queryParam.allowedKeys contains empty string", () => { + const config: SessionContextConfig = { + queryParam: { + allowedKeys: ["valid", ""], + }, + }; + expect(() => validateSessionContextConfig(config)).toThrow( + "queryParam.allowedKeys[1] must be a non-empty string" + ); + }); + }); + + describe("invalid contextResolver", () => { + it("throws when contextResolver is not a function", () => { + const config = { + contextResolver: "not-a-function", + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "contextResolver must be a function" + ); + }); + + it("throws when contextResolver is an object", () => { + const config = { + contextResolver: { key: "value" }, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + "contextResolver must be a function" + ); + }); + }); + + describe("invalid merge strategy", () => { + it("throws when merge is invalid string", () => { + const config = { + merge: "invalid", + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + 'Invalid merge strategy: "invalid". Must be "shallow" or "deep"' + ); + }); + + it("throws when merge is not a string", () => { + const config = { + merge: 123, + } as any; + expect(() => validateSessionContextConfig(config)).toThrow( + 'Invalid merge strategy: "123". Must be "shallow" or "deep"' + ); + }); + }); +});