| 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, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.progress`, `ctx.enrich`, `ctx.content`), and when to use each.
|
| metadata | {"author":"cyanheads","version":"1.9","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, optional protocol capabilities (elicitation), cancellation, and task progress — 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 {
readonly requestId: string;
readonly timestamp: string;
readonly tenantId?: string;
readonly sessionId?: string;
readonly traceId?: string;
readonly spanId?: string;
readonly auth?: AuthContext;
readonly log: ContextLogger;
readonly state: ContextState;
readonly elicit?: ElicitFn;
readonly notifyResourceListChanged?: () => void;
readonly notifyResourceUpdated?: (uri: string) => void;
readonly notifyPromptListChanged?: () => void;
readonly notifyToolListChanged?: () => void;
readonly signal: AbortSignal;
readonly progress?: ContextProgress;
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 | OTEL trace context |
spanId | When OTEL enabled | OTEL trace context |
auth | When auth enabled | Parsed JWT claims |
ctx.log
Request-scoped structured logger. Every log line is automatically annotated with requestId, traceId, and tenantId — no manual spreading needed.
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.
- 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) |
HTTP, stateful / auto, MCP_AUTH_MODE=jwt / oauth | session token, identity-bound — hijack mismatches are rejected by SessionStore.isValidForIdentity before the handler runs |
In stateful / auto mode, the value mirrors the Mcp-Session-Id HTTP header (or a server-minted token for new sessions). Each subsequent request from the same client reuses it; reconnects after disconnect bind to the same session as long as it hasn't expired.
Stateless-mode opt-in
In stateless HTTP mode the SDK still hands the framework a freshly generated token for every request, but it has request-lifetime semantics (no SessionStore, no continuity). The framework hides this from handlers by default — ctx.sessionId is undefined so any handler treating it as durable fails closed.
To surface the per-request token anyway, opt in via createApp:
import { createApp } from '@cyanheads/mcp-ts-core';
await createApp({
tools: [...],
context: {
exposeStatelessSessionId: true,
},
});
Use this only when downstream code is structured around ctx.sessionId and accepts that the value changes per-request. For generic per-request correlation, use ctx.requestId (always present, no opt-in).
Capability-token model
Surfacing sessionId does not change the framework's capability-as-token rule (possession of an opaque ID grants access — see CLAUDE.md/AGENTS.md # Core Rules). It is an opt-in discovery-scoping axis, not an access boundary.
- Tokens shared across sessions (e.g.
df_<uuid> handed from Agent A to Agent B) still resolve on the receiving side. The lookup key is the token, not the session.
- Session-scoped enumeration (e.g.
dataframe_describe returning only items registered by the current session) is a per-server pattern: maintain a session-keyed lookup of known names, gate list-all on it, but route direct lookups against the shared backing store.
This matches deployments like brapi-mcp-server under MCP_AUTH_MODE=none: each session gets its own _connect alias surface and its own dataframe_describe enumeration scope, while any agent holding a df_<uuid> token can query it directly across session boundaries.
Recipes
Strict — fail closed when no session is present:
import { invalidRequest } from '@cyanheads/mcp-ts-core/errors';
if (!ctx.sessionId) {
throw invalidRequest('Session required for this operation.');
}
await ctx.state.set(`session:${ctx.sessionId}:${baseKey}`, value);
Lax — fall back to tenant-shared key:
const sessionKey = ctx.sessionId
? `session:${ctx.sessionId}:${baseKey}`
: baseKey;
await ctx.state.set(sessionKey, value);
Reading the matching log correlation field. The framework's auto-instrumented logs always carry the raw SDK session token (even in stateless mode, for tracing) under the sessionId field. Don't read ctx.sessionId and pass it to ctx.log — the logger already has it.
Behavior notes
- Not a tenant boundary.
ctx.state is still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix with session:${ctx.sessionId}: as shown above.
- Auto-task tools.
task: true handlers run in a detached background context with no session attachment — ctx.sessionId is always undefined regardless of mode.
- Worker bundle. Workers use the same HTTP transport plumbing; session behavior matches Node HTTP.
ctx.elicit
Optional — undefined when the connected client doesn't advertise the elicitation capability (checked per request, after the initialize handshake). Check for presence before calling. A simple truthiness check is enough; no type guards needed.
ctx.elicit is an ElicitFn (exported from the main entry): directly callable for form-mode elicitation, with a .url(message, url) method for URL-mode. On the wire, the Zod schema is converted to the restricted flat JSON Schema the MCP spec requires — handlers never deal with that detail.
ctx.elicit — ask the user for structured input
Presents a form to the user via the MCP elicitation protocol. The user fills in a Zod-validated schema and returns an action (accept, decline, or cancel).
if (ctx.elicit) {
const result = await ctx.elicit(
'Which output format do you want?',
z.object({
format: z.enum(['json', 'csv', 'markdown']).describe('Output format'),
includeHeaders: z.boolean().default(true).describe('Include column headers'),
}),
);