Loading
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`), and when to use each.
Recommended by author
## 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
```ts
import type { Context } from '@cyanheads/mcp-ts-core';
interface Context {
// Identity & tracing
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; // OTEL trace ID (present when OTEL enabled)
readonly spanId?: string; // OTEL span ID (present when OTEL enabled)
readonly auth?: AuthContext; // Parsed auth claims (clientId, scopes, sub)
// Structured logging — auto-includes requestId, traceId, tenantId
readonly log: ContextLogger;
// Tenant-scoped key-value storage
readonly state: ContextState;
// Optional protocol capabilities (undefined when client doesn't support them)
readonly elicit?: ElicitFn; // callable (message, schema) for form mode + .url(message, url) — see § ctx.elicit
// 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;
// Task progress — present only when tool is defined with task: true
readonly progress?: ContextProgress;
// 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;
// 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 | 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
```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.
- **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) |
| 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`:
```ts
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:**
```ts
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:**
```ts
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`).
```ts
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'),
}),
);
if (result.action === 'accept') {
// result.content is Record<string, string | number | boolean | string[]> | undefined
await produceOutput(result.content?.format as string, result.content?.includeHeaders as boolean);
} else {
// 'decline' or 'cancel' — user opted out
throw invalidRequest('User declined input');
}
}
```
`ElicitResult` (from `@modelcontextprotocol/sdk/types.js`):
```ts
// Actual SDK type — a flat object, not a discriminated union
interface ElicitResult {
action: 'accept' | 'decline' | 'cancel';
// Present when action === 'accept'; values are primitives or string arrays
content?: Record<string, string | number | boolean | string[]>;
}
```
> **Note:** `content` is not typed against the Zod schema you pass — it is a `Record` of primitives. Validate `content` against your schema manually (e.g. `MySchema.parse(result.content)`) when `action === 'accept'`.
### `ctx.elicit.url` — hand the user an external link
URL-mode elicitation (MCP 2025-11-25): instead of an inline form, the client directs the user to an external URL — authorization flows, hosted forms. The framework generates the protocol-required `elicitationId` internally.
```ts
if (ctx.elicit) {
const result = await ctx.elicit.url(
'Authorize access to your account',
'https://example.com/oauth/authorize?state=...',
);
if (result.action !== 'accept') throw forbidden('Authorization declined');
}
```
`result.content` is absent in URL mode — the interaction completes out-of-band; only `action` reports the outcome.
**Convention:** Only call `ctx.elicit` from tool handlers, not from services.
---
## List-changed notifications (`ctx.notify*`)
Fire-and-forget signals that the tool / resource / prompt list changed (the client should re-list), or that a specific resource was updated. The framework advertises the matching `listChanged` capabilities on every `initialize`. All four are wired in every tool and resource handler context — call with optional chaining (`?.`), the type is optional for mock / forward-compat only.
```ts
async handler(input, ctx) {
await enableFeatureTools();
ctx.notifyToolListChanged?.(); // tells the client to re-fetch tools/list
return { ok: true };
}
```
### Delivery
A notification fired **from inside a handler** routes through that request's own channel (`relatedRequestId`), so it reaches the client on **every transport** — stdio, HTTP, and Workers — even though HTTP/Workers run a per-request `McpServer` with no long-lived notification channel.
| Fired from | stdio | HTTP / Workers |
|:-----------|:------|:---------------|
| A tool / resource handler | ✅ delivered | ✅ delivered (on the request's SSE response stream) |
| A `task: true` background handler, cron, or any non-request scope | ✅ delivered | ⚠️ dropped — no request scope to route through |
The background-under-HTTP gap is a known limitation; a session-scoped notification bus would close it. `notifyResourceUpdated` routes to the calling request, not to clients that subscribed to the URI — the framework tracks no subscription state.
---
## `ctx.signal`
Standard `AbortSignal`. Present on every context. Set when the client cancels the request or when a task tool is cancelled.
```ts
// Check before expensive operations
if (ctx.signal.aborted) return earlyResult;
// Pass through to fetch / other async APIs
const response = await fetch(url, { signal: ctx.signal });
// Loop with cancellation check
for (const item of items) {
if (ctx.signal.aborted) break;
await processItem(item);
}
```
In task tools (`task: true`), the framework signals `ctx.signal` when the client sends a cancellation request.
---
## `ctx.progress`
Present only when the tool definition includes `task: true`. Undefined for standard (non-task) tools and all resource handlers.
### Methods
| Method | Purpose |
|:-------|:--------|
| `ctx.progress.setTotal(n)` | Set the total number of steps (enables percentage calculation on client) |
| `ctx.progress.increment(amount?)` | Advance progress by `amount` (default: 1) |
| `ctx.progress.update(message)` | Send a descriptive status message without advancing the counter |
### Usage
```ts
const asyncCountdown = tool('async_countdown', {
description: 'Count down from a number with progress updates.',
task: true,
input: z.object({
count: z.number().int().positive().describe('Number to count down from'),
delayMs: z.number().default(1000).describe('Delay between counts in ms'),
}),
output: z.object({
finalCount: z.number().describe('Final count value'),
message: z.string().describe('Completion message'),
}),
async handler(input, ctx) {
await ctx.progress!.setTotal(input.count);
for (let i = input.count; i > 0; i--) {
if (ctx.signal.aborted) break;
await ctx.progress!.update(`Counting: ${i}`);
await new Promise(resolve => setTimeout(resolve, input.delayMs));
await ctx.progress!.increment();
}
return { finalCount: 0, message: 'Countdown complete' };
},
});
```
**Note:** Use the non-null assertion (`ctx.progress!`) when accessing inside a `task: true` handler — the type is `ContextProgress | undefined` even though it's guaranteed present at runtime. TypeScript cannot narrow based on the `task` flag.
---
## `ctx.uri`
Present only for resource handlers. The raw `URL` object for the matched resource URI.
```ts
export const myResource = resource('myscheme://{itemId}/data', {
async handler(params, ctx) {
ctx.log.debug('Resource accessed', { uri: ctx.uri?.toString() });
// params.itemId is extracted from the URI pattern — prefer params over ctx.uri
return fetchItem(params.itemId);
},
});
```
Prefer `params` (the extracted URI template variables) over parsing `ctx.uri` manually. `ctx.uri` is available when the raw URL string is needed.
---
## `ctx.fail`
Present only when the definition declares an `errors[]` contract. Builds an `McpError` keyed by the contract's `reason` union, so the resulting code is consistent with what the tool advertises in `tools/list`.
— [truncated; see full source: https://github.com/cyanheads/mcp-ts-core]Running prompts needs a free account.
Sign in and we'll stream the response from Claude Opus 4.7 right here — no config needed for the platform models.
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`), and when to use each.
## 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
```ts
import type { Context } from '@cyanheads/mcp-ts-core';
interface Context {
// Identity & tracing
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; // OTEL trace ID (present when OTEL enabled)
readonly spanId?: string; // OTEL span ID (present when OTEL enabled)
readonly auth?: AuthContext; // Parsed auth claims (clientId, scopes, sub)
// Structured logging — auto-includes requestId, traceId, tenantId
readonly log: ContextLogger;
// Tenant-scoped key-value storage
readonly state: ContextState;
// Optional protocol capabilities (undefined when client doesn't support them)
readonly elicit?: ElicitFn; // callable (message, schema) for form mode + .url(message, url) — see § ctx.elicit
// 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;
// Task progress — present only when tool is defined with task: true
readonly progress?: ContextProgress;
// 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;
// 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 | 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
```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.
- **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) |
| 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`:
```ts
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:**
```ts
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:**
```ts
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`).
```ts
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'),
}),
);
if (result.action === 'accept') {
// result.content is Record<string, string | number | boolean | string[]> | undefined
await produceOutput(result.content?.format as string, result.content?.includeHeaders as boolean);
} else {
// 'decline' or 'cancel' — user opted out
throw invalidRequest('User declined input');
}
}
```
`ElicitResult` (from `@modelcontextprotocol/sdk/types.js`):
```ts
// Actual SDK type — a flat object, not a discriminated union
interface ElicitResult {
action: 'accept' | 'decline' | 'cancel';
// Present when action === 'accept'; values are primitives or string arrays
content?: Record<string, string | number | boolean | string[]>;
}
```
> **Note:** `content` is not typed against the Zod schema you pass — it is a `Record` of primitives. Validate `content` against your schema manually (e.g. `MySchema.parse(result.content)`) when `action === 'accept'`.
### `ctx.elicit.url` — hand the user an external link
URL-mode elicitation (MCP 2025-11-25): instead of an inline form, the client directs the user to an external URL — authorization flows, hosted forms. The framework generates the protocol-required `elicitationId` internally.
```ts
if (ctx.elicit) {
const result = await ctx.elicit.url(
'Authorize access to your account',
'https://example.com/oauth/authorize?state=...',
);
if (result.action !== 'accept') throw forbidden('Authorization declined');
}
```
`result.content` is absent in URL mode — the interaction completes out-of-band; only `action` reports the outcome.
**Convention:** Only call `ctx.elicit` from tool handlers, not from services.
---
## List-changed notifications (`ctx.notify*`)
Fire-and-forget signals that the tool / resource / prompt list changed (the client should re-list), or that a specific resource was updated. The framework advertises the matching `listChanged` capabilities on every `initialize`. All four are wired in every tool and resource handler context — call with optional chaining (`?.`), the type is optional for mock / forward-compat only.
```ts
async handler(input, ctx) {
await enableFeatureTools();
ctx.notifyToolListChanged?.(); // tells the client to re-fetch tools/list
return { ok: true };
}
```
### Delivery
A notification fired **from inside a handler** routes through that request's own channel (`relatedRequestId`), so it reaches the client on **every transport** — stdio, HTTP, and Workers — even though HTTP/Workers run a per-request `McpServer` with no long-lived notification channel.
| Fired from | stdio | HTTP / Workers |
|:-----------|:------|:---------------|
| A tool / resource handler | ✅ delivered | ✅ delivered (on the request's SSE response stream) |
| A `task: true` background handler, cron, or any non-request scope | ✅ delivered | ⚠️ dropped — no request scope to route through |
The background-under-HTTP gap is a known limitation; a session-scoped notification bus would close it. `notifyResourceUpdated` routes to the calling request, not to clients that subscribed to the URI — the framework tracks no subscription state.
---
## `ctx.signal`
Standard `AbortSignal`. Present on every context. Set when the client cancels the request or when a task tool is cancelled.
```ts
// Check before expensive operations
if (ctx.signal.aborted) return earlyResult;
// Pass through to fetch / other async APIs
const response = await fetch(url, { signal: ctx.signal });
// Loop with cancellation check
for (const item of items) {
if (ctx.signal.aborted) break;
await processItem(item);
}
```
In task tools (`task: true`), the framework signals `ctx.signal` when the client sends a cancellation request.
---
## `ctx.progress`
Present only when the tool definition includes `task: true`. Undefined for standard (non-task) tools and all resource handlers.
### Methods
| Method | Purpose |
|:-------|:--------|
| `ctx.progress.setTotal(n)` | Set the total number of steps (enables percentage calculation on client) |
| `ctx.progress.increment(amount?)` | Advance progress by `amount` (default: 1) |
| `ctx.progress.update(message)` | Send a descriptive status message without advancing the counter |
### Usage
```ts
const asyncCountdown = tool('async_countdown', {
description: 'Count down from a number with progress updates.',
task: true,
input: z.object({
count: z.number().int().positive().describe('Number to count down from'),
delayMs: z.number().default(1000).describe('Delay between counts in ms'),
}),
output: z.object({
finalCount: z.number().describe('Final count value'),
message: z.string().describe('Completion message'),
}),
async handler(input, ctx) {
await ctx.progress!.setTotal(input.count);
for (let i = input.count; i > 0; i--) {
if (ctx.signal.aborted) break;
await ctx.progress!.update(`Counting: ${i}`);
await new Promise(resolve => setTimeout(resolve, input.delayMs));
await ctx.progress!.increment();
}
return { finalCount: 0, message: 'Countdown complete' };
},
});
```
**Note:** Use the non-null assertion (`ctx.progress!`) when accessing inside a `task: true` handler — the type is `ContextProgress | undefined` even though it's guaranteed present at runtime. TypeScript cannot narrow based on the `task` flag.
---
## `ctx.uri`
Present only for resource handlers. The raw `URL` object for the matched resource URI.
```ts
export const myResource = resource('myscheme://{itemId}/data', {
async handler(params, ctx) {
ctx.log.debug('Resource accessed', { uri: ctx.uri?.toString() });
// params.itemId is extracted from the URI pattern — prefer params over ctx.uri
return fetchItem(params.itemId);
},
});
```
Prefer `params` (the extracted URI template variables) over parsing `ctx.uri` manually. `ctx.uri` is available when the raw URL string is needed.
---
## `ctx.fail`
Present only when the definition declares an `errors[]` contract. Builds an `McpError` keyed by the contract's `reason` union, so the resulting code is consistent with what the tool advertises in `tools/list`.
— [truncated; see full source: https://github.com/cyanheads/mcp-ts-core]{{basekey}}{{i}}