ADR-025: Request Context Propagation over JSON-RPC via AsyncLocalStorage
Status
Accepted
Date
2026-07-28
Context
In @nogoo9/no-crd, Model Context Protocol (MCP) clients invoke spawner and workspace orchestration tools over HTTP via the JSON-RPC endpoint /mcp.
When an HTTP request arrives at /mcp:
- Fastify processes HTTP authentication in a
preHandlerhook, validating the Bearer JWT or session cookie and attaching the caller's JWT payload (jwtPayloadcontainingsub,roles,isAdmin) to the HTTP request object. - The MCP SDK's transport layer (
WebStandardStreamableHTTPServerTransport) decodes the incoming JSON-RPC payload and dispatches tool requests asynchronously inside its internal transport handler callbacks.
Because transport callbacks execute outside Fastify's route handler scope, the tool handler functions (list_workspaces, spawn_workspace, upgrade_workspace, upgrade_all_workspaces, etc.) execute without direct access to the outer Fastify request or reply objects.
Problem Statement
Without a mechanism to propagate request context, tool handlers cannot determine:
- Who called the tool (
authCtx.sub). - Whether the caller has administrator privileges (
authCtx.isAdmin). - What RBAC roles the caller possesses (
authCtx.roles).
Passing jwtPayload as an explicit parameter through every transport function would require mutating standard MCP SDK interfaces, creating heavy coupling and risking missing parameters.
Decision
We use Node.js's built-in AsyncLocalStorage API (requestContextStore) to propagate caller authentication context across HTTP preHandler hooks and MCP transport JSON-RPC tool handler bounds.
Architecture
Implementation Details
Context Store Definition ([
src/k8s/client.ts](file:///home/eterna2/github/nogoo9-no-crd/src/k8s/client.ts)):typescriptexport interface RequestContext { jwtPayload?: Record<string, unknown>; } export const requestContextStore = new AsyncLocalStorage<RequestContext>();HTTP Middleware Binding ([
src/server/routes/mcp.ts](file:///home/eterna2/github/nogoo9-no-crd/src/server/routes/mcp.ts)):typescript// Wrap transport.handleRequest inside requestContextStore.run await requestContextStore.run({ jwtPayload }, async () => { await transport.handleRequest(request.raw, reply.raw); });Tool Handler Access ([
src/mcp/spawner/handlers.ts](file:///home/eterna2/github/nogoo9-no-crd/src/mcp/spawner/handlers.ts)):typescriptfunction resolveActiveJwt(jwtPayload?: Record<string, unknown>): Record<string, unknown> | undefined { const store = requestContextStore.getStore(); return jwtPayload || store?.jwtPayload; }
Consequences
Positive
- Stateless & Clean Signatures: MCP tool handler signatures remain clean and compliant with standard MCP SDK tool definitions without requiring custom request wrapper parameters.
- Strict Concurrency Isolation:
AsyncLocalStorageguarantees that concurrent HTTP requests executing on the event loop maintain separate, isolated context stores. - Seamless Fallback: Allows tool handlers to operate under both MCP stdio transport (where auth is bypassed for local single-user execution) and HTTP transport (where multi-user JWT context is required).
Negative
- Async Context Lifecycle Dependency: Callbacks must maintain the async call stack chain. Wrapping promises inside manual
setTimeoutwithout preserving context could detach the store (handled safely via standard async/await).
