Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
cyanheads avatar

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-context

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs1
repo stars9
Last updatedJuly 30, 2026
Repositorycyanheads/pubchem-mcp-server

What it does

Helps with backend & apis tasks.

Files

SKILL.mdMarkdownGitHub ↗

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 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

FieldAlways presentSource
requestIdYesAuto-generated UUID per request
timestampYesISO 8601, request start
tenantIdStdio and HTTP+MCP_AUTH_MODE=none (as 'default'); JWT tid claim in HTTP+jwt/oauthJWT / single-tenant default
sessionIdHTTP stateful / auto mode; undefined for stdio and stateless HTTP unless opted inMcp-Session-Id header (or server-minted) — see § `ctx.sessionId`
traceIdWhen OTEL enabledOTEL trace context
spanIdWhen OTEL enabledOTEL trace context
authWhen auth enabledParsed JWT claims

---

ctx.log

Request-scoped structured logger. Every log line is automatically annotated with requestId, traceId, and tenantId — no manual spreading needed.

Methods

MethodLevel
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

UseWhere
ctx.logInside tool/resource handlers — auto-correlated to the request
core.logger / loggerIn 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) 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 / modectx.sessionId
stdio (any auth)undefined
HTTP, MCP_SESSION_MODE=statelessundefined (default) — see opt-in
HTTP, stateful / auto, MCP_AUTH_MODE=nonesession token; possession = access (no identity binding)
HTTP, stateful / auto, MCP_AUTH_MODE=jwt / oauthsession 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'),
    }),
  );

  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: 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.

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 fromstdioHTTP / 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

MethodPurpose
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
): McpError

Behavior

AspectDetail
Code resolutioncode comes from the matching contract entry — never from the caller. The thrown McpError.code always equals what's advertised in tools/list.
Default messageWhen message is omitted, the contract entry's when text is used.
data.reasonAuto-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 chainsPass { cause: e } to preserve the original error — pino-pretty and observability platforms render the chain automatically.
Unknown reasonIf 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

AspectDetail
No contract attachedReturns {} — spread is a no-op. Always safe.
Unknown reasonReturns {} (TS prevents this for typed callers; runtime is loose for JS / stale contracts).
Declared reasonReturns { recovery: { hint: <contract.recovery> } } — spread into data.
OverrideCaller can override by spreading recoveryFor first then writing recovery: { hint: '...' } after — last write wins.
Service usageServices 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

AspectDetail
AccumulationEach call merges its fields onto the request; later calls override earlier keys.
Both surfacesMerged into structuredContent (validated against output.extend(enrichment)) and appended to content[] as a trailer — even when the tool defines no format().
Domain payload untouchedcontent[] 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 guardA required enrichment field never populated fails the effective-output parse — the bug surfaces loudly rather than dropping silently.
No blockCalling 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 usageServices accepting ctx: Context can call ctx.enrich(...); the value reaches structuredContent exactly as if the handler had.
format-parityEnrichment lives outside output, so the format-parity lint never requires it in format().
Trailer renderingPer 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 writtenKeyNotes
truncatedtrueAlways
shownnumberAlways
capnumberAlways
truncationCeilingnumberOnly when ceiling is passed
noticestringVia 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

PropertyTypePresent when
ctx.requestIdstringAlways
ctx.timestampstringAlways
ctx.tenantId`string \undefined`
ctx.sessionId`string \undefined`
ctx.traceId`string \undefined`
ctx.spanId`string \undefined`
ctx.auth`AuthContext \undefined`
ctx.logContextLoggerAlways
ctx.stateContextStateAlways (throws if tenantId missing)
ctx.signalAbortSignalAlways
ctx.enrichEnrichAlways; 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?) => McpErrorDefinition declares errors[] contract
ctx.recoveryFor`(reason) => { recovery: { hint } } \{}`

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.