
Api Context
- 1 installs
- 9 repo stars
- Updated July 30, 2026
- cyanheads/pubchem-mcp-server
Helps with backend & apis tasks.
About
api-context is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted coding.
- api-context
- Backend & APIs
- AI-coding skill
Api Context by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,836 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cyanheads/pubchem-mcp-server --skill api-contextAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 30, 2026 |
| Repository | cyanheads/pubchem-mcp-server ↗ |
What it does
Helps with backend & apis tasks.
Files
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 {
// 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 declareserrors: [...], the handler receivesHandlerContext<R> = Context & { fail: TypedFail<R>; recoveryFor: TypedRecoveryFor<R> }— both the typedfailand the strictly-typedrecoveryForlive on the intersection. The bareContext.recoveryForis 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
// 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
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
// 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)iftenantIdis 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/oauthwhen the token lacks atidclaim (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-memoryprovider loses data on cold starts. Usecloudflare-kv,cloudflare-r2, orcloudflare-d1for 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_describereturning 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.stateis still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix withsession:${ctx.sessionId}:as shown above. - Auto-task tools.
task: truehandlers run in a detached background context with no session attachment —ctx.sessionIdis alwaysundefinedregardless 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'),
}),
);
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):
// 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:contentis not typed against the Zod schema you pass — it is aRecordof primitives. Validatecontentagainst your schema manually (e.g.MySchema.parse(result.content)) whenaction === '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.
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.
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.
// 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
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.
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.
export const fetchItems = tool('fetch_items', {
description: 'Fetch items by ID.',
errors: [
{ reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched',
recovery: 'Broaden the query or check the spelling and try again.' },
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity', retryable: true,
recovery: 'Wait a few seconds before retrying or reduce batch size.' },
],
input: z.object({ ids: z.array(z.string()).describe('Item IDs') }),
output: z.object({ items: z.array(ItemSchema).describe('Resolved items') }),
async handler(input, ctx) {
if (queue.full()) throw ctx.fail('queue_full');
const items = await fetch(input.ids);
if (items.length === 0) throw ctx.fail('no_match', `No items match ${input.ids.length} IDs`, { ids: input.ids });
// ctx.fail('typo') ← TypeScript error: 'typo' isn't in the contract
return { items };
},
});Signature
// TypedFail<R> — R is the union of declared `reason` strings, derived from the
// definition's `errors: [...]` const tuple via the framework's `ReasonOf<E>`.
ctx.fail(
reason: R, // union of declared reason strings
message?: string, // defaults to the contract entry's `when` text
data?: Record<string, unknown>, // merged into err.data; cannot override `reason`
options?: { cause?: unknown }, // ES2022 cause chain
): McpErrorBehavior
| Aspect | Detail |
|---|---|
| Code resolution | code comes from the matching contract entry — never from the caller. The thrown McpError.code always equals what's advertised in tools/list. |
| Default message | When message is omitted, the contract entry's when text is used. |
data.reason | Auto-populated from the contract entry. Caller-supplied data.reason cannot override it — the framework spreads caller data first and writes reason last so observers see a stable identifier. |
| Cause chains | Pass { cause: e } to preserve the original error — pino-pretty and observability platforms render the chain automatically. |
| Unknown reason | If the type-system guard is bypassed (JS caller, stale contract), ctx.fail returns an McpError(InternalError) with data.reason and data.declaredReasons set so the bug is loud rather than silent. |
Without a contract
When the definition has no errors[] field, ctx is plain Context and ctx.fail is absent. Throw McpError directly (or via factory):
import { notFound, rateLimited } from '@cyanheads/mcp-ts-core/errors';
async handler(input, ctx) {
if (queue.full()) throw rateLimited('Queue at capacity');
const items = await fetch(input.ids);
if (items.length === 0) throw notFound(`No items match ${input.ids.length} IDs`);
return { items };
}The contract is opt-in. See skills/api-errors/SKILL.md for the full type-driven pattern, lint rules, and baseline-codes guidance.
---
ctx.recoveryFor
Always present on Context. Resolves the contract recovery for a given reason and returns the canonical wire shape { recovery: { hint } }, ready to spread into data. The first member of a planned family of opt-in resolution helpers (future: troubleshootingFor, userMessageFor, …).
async handler(input, ctx) {
// Static recovery — pulled from the contract entry, no string duplication.
if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
// Dynamic recovery — interpolate runtime context, override the contract default.
if (!matched) throw ctx.fail('no_match', `No items for "${input.query}"`, {
recovery: { hint: `Try a broader query than "${input.query}", or check spelling.` },
});
}Signature
// Loose (always present on Context — works without a contract attached):
ctx.recoveryFor(reason: string): { recovery: { hint: string } } | {}
// Strict (HandlerContext<R> when the definition declares errors[]):
ctx.recoveryFor(reason: R): { recovery: { hint: string } }Behavior
| Aspect | Detail |
|---|---|
| No contract attached | Returns {} — spread is a no-op. Always safe. |
| Unknown reason | Returns {} (TS prevents this for typed callers; runtime is loose for JS / stale contracts). |
| Declared reason | Returns { recovery: { hint: <contract.recovery> } } — spread into data. |
| Override | Caller can override by spreading recoveryFor first then writing recovery: { hint: '...' } after — last write wins. |
| Service usage | Services that accept ctx: Context can spread ctx.recoveryFor('reason') directly; the no-op fallback means they don't need to know which tool called them. |
Why opt-in resolution, not auto-population
The framework never injects data.recovery.hint without an explicit signal at the throw site. Authors opt in by typing ctx.recoveryFor('reason') — the same way ctx.fail('reason') opts into resolving the contract code. The contract is the single source of truth for the recovery hint; the resolver is a typed lookup keyed by the same reason the author already typed. No magic, no hidden transformation.
The ≥5 words lint rule on contract recovery (validated at lint time) makes this load-bearing — every ctx.recoveryFor call site benefits from the thoughtfulness the contract enforced.
---
ctx.enrich
Always present on Context. Accumulates agent-facing success-path context — empty-result notices, the query/filter as the server parsed it, pagination totals — onto the request. The framework merges it into structuredContent, advertises output.extend(enrichment) as the tool's outputSchema, and mirrors it into a content[] trailer. The success-path counterpart to ctx.fail / ctx.recoveryFor.
export const search = tool('search', {
description: 'Search the catalog.',
input: z.object({ query: z.string().describe('Search terms') }),
output: z.object({ items: z.array(z.string()).describe('Matching items') }),
enrichment: {
effectiveQuery: z.string().describe('Query as the server parsed it'),
totalCount: z.number().describe('Total matches before the limit'),
notice: z.string().optional().describe('Guidance when nothing matched'),
},
async handler(input, ctx) {
const res = await runSearch(input.query);
ctx.enrich.echo(res.parsed); // → effectiveQuery + "Query: …" trailer
ctx.enrich.total(res.total); // → totalCount + "N total" trailer
if (res.items.length === 0) ctx.enrich.notice(`No matches for "${input.query}".`);
return { items: res.items }; // enrichment never rides in the domain return
},
});Signature
// Loose (always present on Context — works without a block; service-callable):
ctx.enrich(fields: Record<string, unknown>): void
// Strict (HandlerContext<R, E> when the definition declares an enrichment block):
ctx.enrich(fields: Partial<z.infer<ZodObject<E>>>): void
// Kind-tagged field-helpers (always present) — write a conventional key and tag
// the content[] trailer rendering:
ctx.enrich.notice(text: string): void // writes `notice` → blockquote
ctx.enrich.total(count: number): void // writes `totalCount` → "N total"
ctx.enrich.echo(query: string): void // writes `effectiveQuery` → "Query: …"
ctx.enrich.delta({ field, before, after }): void // writes `{before, after}` → "field: before → after"
// Truncation disclosure — for capped lists:
ctx.enrich.truncated({ shown, cap, ceiling?, guidance? }): void
// writes: truncated=true, shown, cap, truncationCeiling? (if ceiling provided)
// also writes notice via guidance or a generated default (last-wins with other notice calls)Behavior
| Aspect | Detail |
|---|---|
| Accumulation | Each call merges its fields onto the request; later calls override earlier keys. |
| Both surfaces | Merged into structuredContent (validated against output.extend(enrichment)) and appended to content[] as a trailer — even when the tool defines no format(). |
| Domain payload untouched | content[] renders the handler's return via format() (or the JSON default); enrichment is a separate trailer, never double-rendered. The handler return must NOT carry enrichment fields. |
| Required-field guard | A required enrichment field never populated fails the effective-output parse — the bug surfaces loudly rather than dropping silently. |
| No block | Calling ctx.enrich on a tool that declared no enrichment is a silent no-op (values are stripped by the parse) — the price of service-layer callability. |
| Service usage | Services accepting ctx: Context can call ctx.enrich(...); the value reaches structuredContent exactly as if the handler had. |
format-parity | Enrichment lives outside output, so the format-parity lint never requires it in format(). |
| Trailer rendering | Per field: kind-tag if set (notice/total/echo/delta), else the definition's enrichmentTrailer.render/label, else **key:** value (objects/arrays JSON.stringify'd). A structured field with no render errors under enrichment-trailer-render — supply one so it renders as markdown; structuredContent keeps the full value regardless. |
ctx.enrich.truncated() — capped-list disclosure
For tools that cap a list (i.e. have a limit/per_page/page_size/max_results/max_items input), call truncated() when the cap was actually hit:
enrichment: {
truncated: z.boolean().describe('True when the list was capped.'),
shown: z.number().describe('Number of items returned.'),
cap: z.number().describe('The limit that was applied.'),
truncationCeiling: z.number().optional().describe('Upper bound for omitted items (threshold bound).'),
},
async handler(input, ctx) {
const items = await fetch(input.limit);
if (items.length >= input.limit) {
ctx.enrich.truncated({
shown: items.length,
cap: input.limit,
ceiling: items.at(-1)?.count, // optional — only when list sorted by cap key
guidance: 'Narrow with filters or raise per_page (max 200).',
});
}
return { items };
},| Field written | Key | Notes |
|---|---|---|
truncated | true | Always |
shown | number | Always |
cap | number | Always |
truncationCeiling | number | Only when ceiling is passed |
notice | string | Via guidance or a generated default; last-wins — a handler with multiple notice sources (e.g. both truncation and empty-result) should compose them into one string passed as guidance, or call truncated() after the other notice calls. |
The capped-list-no-truncation lint rule fires when a cap-like input + array output shape is present without any of: truncated or totalCount in the declared enrichment, or truncated or totalCount in output. Using ctx.enrich.total(n) (writes totalCount) is also recognized as honest disclosure.
See add-tool's Tool Response Design and skills/api-linter (enrichment-* rules) for the full pattern. Test enrichment with getEnrichment(ctx) from @cyanheads/mcp-ts-core/testing.
---
Quick reference
| Property | Type | Present when |
|---|---|---|
ctx.requestId | string | Always |
ctx.timestamp | string | Always |
ctx.tenantId | `string \ | undefined` |
ctx.sessionId | `string \ | undefined` |
ctx.traceId | `string \ | undefined` |
ctx.spanId | `string \ | undefined` |
ctx.auth | `AuthContext \ | undefined` |
ctx.log | ContextLogger | Always |
ctx.state | ContextState | Always (throws if tenantId missing) |
ctx.signal | AbortSignal | Always |
ctx.enrich | Enrich | Always; typed on HandlerContext<R, E> when an enrichment block is declared |
ctx.elicit | `function \ | undefined` |
ctx.notifyResourceListChanged | `function \ | undefined` |
ctx.notifyResourceUpdated | `function \ | undefined` |
ctx.notifyPromptListChanged | `function \ | undefined` |
ctx.notifyToolListChanged | `function \ | undefined` |
ctx.progress | `ContextProgress \ | undefined` |
ctx.uri | `URL \ | undefined` |
ctx.fail | (reason, msg?, data?, opts?) => McpError | Definition declares errors[] contract |
ctx.recoveryFor | `(reason) => { recovery: { hint } } \ | {}` |