- 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.1","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
```ts
import type { Context } from '@cyanheads/mcp-ts-core';
interface Context extends RequestContext {
// Identity & tracing (inherited from RequestContext — see § RequestContext)
readonly requestId: string; // Unique per request, auto-generated
readonly timestamp: string; // ISO 8601 request start time
readonly tenantId?: string; // JWT 'tid' claim; 'default' for stdio and HTTP+MCP_AUTH_MODE=none
readonly sessionId?: string; // Mcp-Session-Id (HTTP stateful/auto); undefined elsewhere unless opted in
readonly traceId?: string; // Trace containing this handler execution
readonly spanId?: string; // The handler's own execution span
readonly auth?: AuthContext; // Parsed auth claims (clientId, scopes, sub)
readonly operation?: string; // Label for the operation this context belongs to
readonly extra?: Readonly<Record<string, unknown>>; // Correlation bag — the one open field
// Structured logging — auto-includes requestId, traceId, tenantId.
// Dual-sink: Pino on the server, plus notifications/message to the client.
readonly log: ContextLogger;
// Tenant-scoped key-value storage
readonly state: ContextState;
// Multi-round-trip input — always present, both eras (see § ctx.requestInput)
readonly requestInput: RequestInputFn; // (spec) => never — suspends and asks the caller
readonly inputs: ContextInputs; // reader over a retried request's responses
// List-changed / resource-updated notifications — wired in every handler ctx;
// delivery is request-scoped (see § list-changed notifications)
readonly notifyResourceListChanged?: () => void;
readonly notifyResourceUpdated?: (uri: string) => void;
readonly notifyPromptListChanged?: () => void;
readonly notifyToolListChanged?: () => void;
// Cancellation
readonly signal: AbortSignal;
// Raw URI — present only for resource handlers
readonly uri?: URL;
// Agent-facing success-path enrichment — accumulates notices, query echo, totals
// onto the request; reaches structuredContent + content[]. Always present (no-op
// when no `enrichment` block), strictly typed on HandlerContext<R, E> against the
// declared fields. Kind-tagged helpers: enrich.notice / .total / .echo.
readonly enrich: Enrich;
// Non-text content blocks (image/audio bytes) for the calling model — prepended
// to content[] after format() runs, never placed in structuredContent. Always
// present (no-op when never called). Helpers: content.image / .audio; content(block)
// pushes a raw ContentBlock.
readonly content: ContentCollect;
// Opt-in contract resolver — always present (returns {} when no contract is attached
// or the reason is unknown), strictly typed on HandlerContext<R> against declared reasons.
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`](#ctxfail) and [`ctx.recoveryFor`](#ctxrecoveryfor) 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`](#ctxsessionid) |
| `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.
```ts
import { requestContextService, withExtra } from '@cyanheads/mcp-ts-core/utils';
import type { RequestContext } from '@cyanheads/mcp-ts-core/utils';
// A service typed against RequestContext accepts a handler ctx directly.
async function fetchUser(id: string, ctx: RequestContext) { /* … */ }
await fetchUser('123', ctx); // ctx is a Context — no conversion
```
### 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:
```ts
// 1. Per-log-call metadata — the common case. Nothing lands on the context.
ctx.log.info('Retrying upstream call', { attempt, url });
// 2. A copy of this context carrying extra fields. `withExtra` MERGES into any
// bag the parent already had; a hand-written `{ ...ctx, extra: {…} }` replaces it.
logger.warning('Retrying upstream call', withExtra(ctx, { attempt, url }));
// 3. A derived context for a sub-operation. `additionalContext` lands on `extra`,
// merged over whatever the parent already carried.
const childCtx = requestContextService.createRequestContext({
parentContext: ctx,
operation: 'processItem',
additionalContext: { itemId: item.id }, // → childCtx.extra.itemId
});
// Reading an ad-hoc key back off a context:
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
```ts
// Basic
ctx.log.info('Processing query', { query: input.query });
// With error object (second arg)
ctx.log.error('Failed to fetch upstream', error, { url, statusCode });
// Debug detail
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
```ts
interface ContextState {
get<T = unknown>(key: string): Promise<T | null>;
get<T>(key: string, schema: ZodType<T>): Promise<T | null>; // runtime-validated
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; // opaque base64url; omitted on last page
}>;
}
```
### Usage
```ts
// Store — accepts any serializable value, no manual JSON.stringify needed
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
// Retrieve — generic type assertion or Zod-validated
const item = await ctx.state.get<Item>('item/123'); // T | null (type assertion)
const safe = await ctx.state.get('item/123', ItemSchema); // T | null (runtime validated)
// Delete
await ctx.state.delete('item/123');
// Batch operations
const values = await ctx.state.getMany<Item>(['item/1', 'item/2']); // Map<string, T>
await ctx.state.setMany(new Map([['a', 1], ['b', 2]]));
const deleted = await ctx.state.deleteMany(['item/1', 'item/2']); // number
// List with prefix + pagination
const page = await ctx.state.list('item/', { cursor, limit: 20 });
for (const { key, value } of page.items) { /* ... */ }
if (page.cursor) { /* more pages available */ }
```
### 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](#stateless-mode-opt-in) |
| HTTP, `stateful` / `auto`, `MCP_AUTH_MODE=none` | session token; possession = access (no identity binding) |
Auf GitHub ansehen