| name | api-context |
| description | Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
|
| metadata | {"author":"cyanheads","version":"2.2","audience":"external","type":"reference"} |
Overview
Every tool and resource handler receives a single Context (ctx) argument. It provides request identity, structured logging, tenant-scoped storage, multi-round-trip input collection, and cancellation — all auto-correlated to the current request.
The framework auto-instruments every handler call (OTel span, duration, payload metrics). Use ctx.log for domain-specific logging and ctx.state for storage inside handlers. Use the global logger and StorageService directly only in lifecycle/background code (setup(), services).
Context interface
import type { Context } from '@cyanheads/mcp-ts-core';
interface Context extends RequestContext {
readonly requestId: string;
readonly timestamp: string;
readonly tenantId?: string;
readonly sessionId?: string;
readonly traceId?: string;
readonly spanId?: string;
readonly auth?: AuthContext;
readonly operation?: string;
readonly extra?: Readonly<Record<string, unknown>>;
readonly log: ContextLogger;
readonly state: ContextState;
readonly requestInput: RequestInputFn;
readonly inputs: ContextInputs;
readonly notifyResourceListChanged?: () => void;
readonly notifyResourceUpdated?: (uri: string) => void;
readonly notifyPromptListChanged?: () => void;
readonly notifyToolListChanged?: () => void;
readonly signal: AbortSignal;
readonly uri?: URL;
readonly enrich: Enrich;
readonly content: ContentCollect;
recoveryFor(reason: string): { recovery: { hint: string } } | {};
}
ctx.fail is on HandlerContext<R>, not Context. When a definition declares errors: [...], the handler receives HandlerContext<R> = Context & { fail: TypedFail<R>; recoveryFor: TypedRecoveryFor<R> } — both the typed fail and the strictly-typed recoveryFor live on the intersection. The bare Context.recoveryFor is the loose, always-present resolver. See ctx.fail and ctx.recoveryFor below.
Identity fields
| Field | Always present | Source |
|---|
requestId | Yes | Auto-generated UUID per request |
timestamp | Yes | ISO 8601, request start |
tenantId | Stdio and HTTP+MCP_AUTH_MODE=none (as 'default'); JWT tid claim in HTTP+jwt/oauth | JWT / single-tenant default |
sessionId | HTTP stateful / auto mode; undefined for stdio and stateless HTTP unless opted in | Mcp-Session-Id header (or server-minted) — see § ctx.sessionId |
traceId | When OTEL enabled | Trace containing this handler execution |
spanId | When OTEL enabled | The active tool_execution:* / resource_read:* span |
auth | When auth enabled | Parsed JWT claims |
RequestContext — the one canonical request shape
Context extends RequestContext. There is a single request-shape type; the handler-facing Context adds handler-only surfaces (log, state, signal, requestInput, inputs, enrich, content, uri) on top of it and redeclares none of the identity fields. A handler's ctx is therefore assignable anywhere a RequestContext is — services, storage, the framework logger — with no slice helper and no cast.
import { requestContextService, withExtra } from '@cyanheads/mcp-ts-core/utils';
import type { RequestContext } from '@cyanheads/mcp-ts-core/utils';
async function fetchUser(id: string, ctx: RequestContext) { }
await fetchUser('123', ctx);
Closed by design
RequestContext has no index signature. Its fields are exactly: auth, extra, operation, requestId, sessionId, spanId, tenantId, timestamp, traceId. A misspelled canonical field (tenatId) is a compile error instead of a silently-ignored key.
Operation-specific correlation data goes in extra — the one deliberate open bag (Readonly<Record<string, unknown>>). The logger flattens extra into the emitted line, so log output looks the same as a top-level spread.
Adding correlation data
Three supported ways, most common first:
ctx.log.info('Retrying upstream call', { attempt, url });
logger.warning('Retrying upstream call', withExtra(ctx, { attempt, url }));
const childCtx = requestContextService.createRequestContext({
parentContext: ctx,
operation: 'processItem',
additionalContext: { itemId: item.id },
});
const itemId = childCtx.extra?.itemId;
createRequestContext(params) takes a closed parameter object — additionalContext, operation, parentContext, tenantId — and nothing else; a key it doesn't declare is a compile error rather than an arbitrary passthrough.
Never re-open the shape to get past a type error: no index signature, no widening a parameter back to Record<string, unknown>, no as cast. A { ...ctx, someKey } object literal that fails to compile is the signal to move someKey into extra, not to loosen the type.
ErrorContext (the ErrorHandler call's context) is Partial<RequestContext> and is closed the same way — put ad-hoc keys under extra via withExtra, or pass them in the ErrorHandler call's own context field.
RequestContextLike is a deprecated alias for RequestContext, kept for one minor. Replace every use with RequestContext, and collapse any RequestContextLike | RequestContext parameter union to plain RequestContext.
ctx.log
Request-scoped structured logger. Every log line is automatically annotated with requestId, traceId, and tenantId — no manual spreading needed.
Dual-sink. Each call writes to Pino and mirrors onto the MCP wire as a notifications/message (the framework advertises the logging capability, and the SDK filters by the level the client set via logging/setLevel). The wire payload is { message, ...data }; ctx.log.error adds error: <message>. Delivery is fire-and-forget — a client that never upgraded to SSE, set a higher level, or already disconnected drops the notification, and a failed send never fails the handler. Treat ctx.log as client-visible: it is no longer a server-only sink, so don't log anything there you wouldn't put in a tool result.
Methods
| Method | Level |
|---|
ctx.log.debug(msg, data?) | Verbose debugging |
ctx.log.info(msg, data?) | Normal operational events |
ctx.log.notice(msg, data?) | Significant but non-error events |
ctx.log.warning(msg, data?) | Recoverable issues, unexpected states |
ctx.log.error(msg, error?, data?) | Errors (second arg is the Error object) |
Usage
ctx.log.info('Processing query', { query: input.query });
ctx.log.error('Failed to fetch upstream', error, { url, statusCode });
ctx.log.debug('Cache miss', { key, ttl });
ctx.log vs global logger
| Use | Where |
|---|
ctx.log | Inside tool/resource handlers — auto-correlated to the request |
core.logger / logger | In setup(), service constructors, background tasks — no request context available |
The global logger is imported from @cyanheads/mcp-ts-core/utils. In handlers, prefer ctx.log.
ctx.state
Tenant-scoped key-value storage. Delegates to StorageService with automatic tenantId scoping — data written under tenant A is invisible to tenant B.
Interface
interface ContextState {
get<T = unknown>(key: string): Promise<T | null>;
get<T>(key: string, schema: ZodType<T>): Promise<T | null>;
set(key: string, value: unknown, opts?: { ttl?: number }): Promise<void>;
delete(key: string): Promise<void>;
deleteMany(keys: string[]): Promise<number>;
getMany<T = unknown>(keys: string[]): Promise<Map<string, T>>;
setMany(entries: Map<string, unknown>, opts?: { ttl?: number }): Promise<void>;
list(prefix?: string, opts?: { cursor?: string; limit?: number }): Promise<{
items: Array<{ key: string; value: unknown }>;
cursor?: string;
}>;
}
Usage
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
await ctx.state.set('session/xyz', token, { ttl: 3600 });
const item = await ctx.state.get<Item>('item/123');
const safe = await ctx.state.get('item/123', ItemSchema);
await ctx.state.delete('item/123');
const values = await ctx.state.getMany<Item>(['item/1', 'item/2']);
await ctx.state.setMany(new Map([['a', 1], ['b', 2]]));
const deleted = await ctx.state.deleteMany(['item/1', 'item/2']);
const page = await ctx.state.list('item/', { cursor, limit: 20 });
for (const { key, value } of page.items) { }
if (page.cursor) { }
Behavior notes
- Throws
McpError(InvalidRequest) if tenantId is missing. Won't happen in stdio (any auth mode) or HTTP+MCP_AUTH_MODE=none — both default to 'default'. Can happen in HTTP+MCP_AUTH_MODE=jwt/oauth when the token lacks a tid claim (intentional fail-closed: distinct authenticated callers must not silently share state).
- Keys are tenant-prefixed internally; handlers never need to namespace manually.
- Key charset:
^[a-zA-Z0-9_.\-/]+$, 1024 chars max, no ... Slashes are the namespace separator — a colon (item:123) throws McpError(ValidationError) on every call. The rule covers list prefixes and every key in a batch operation. createMockContext().state enforces it identically, so an illegal key fails in the test rather than in a deployment.
- Workers persistence: The
in-memory provider loses data on cold starts. Use cloudflare-kv, cloudflare-r2, or cloudflare-d1 for durable storage in Workers.
ctx.sessionId
Optional HTTP session identifier. Surfaced when the request carries a durable session — handlers use it as a discovery / scoping key on top of tenant-keyed ctx.state, not as an authorization principal.
When it's defined
| Transport / mode | ctx.sessionId |
|---|
| stdio (any auth) | undefined |
HTTP, MCP_SESSION_MODE=stateless | undefined (default) — see opt-in |
HTTP, stateful / auto, MCP_AUTH_MODE=none | session token; possession = access (no identity binding) |