agentleFS
Sign inSign up

mcp-ts-core

cyanheads/mcp-ts-core/CLAUDE.md

Package: @cyanheads/mcp-ts-core Version: 0.13.10 Engines: Bun ≥1.4.0, Node ≥24.0.0 MCP SDK: @modelcontextprotocol/server ^2.1.0 (protocol revisions 2026-07-28 and 2025-) *Zod:* ^4.6.5 *GitHub:* cyanheads/mcp-ts-core *npm:* @cyanheads/mcp-ts-core *Docker:** ghcr.io/cyanheads/mcp-ts-core Developer note: Never assume. Read related files and docs before making changes. Read full file content for context. Never try to edit a file before reading it. This package serves two consumer paths. When making changes, know which audience your change affects: Both paths share the same public API. Init copies starter package.json, configs…

CLAUDE.md152 starsChanged 6 days ago
  • Reads credentials
# Developer Protocol

**Package:** `@cyanheads/mcp-ts-core`
**Version:** 0.13.10
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
**MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revisions 2026-07-28 and 2025-*)
**Zod:** ^4.6.5
**GitHub:** [cyanheads/mcp-ts-core](https://github.com/cyanheads/mcp-ts-core)
**npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
**Docker:** [ghcr.io/cyanheads/mcp-ts-core](https://ghcr.io/cyanheads/mcp-ts-core)

> **Developer note:** Never assume. Read related files and docs before making changes. Read full file content for context. Never try to edit a file before reading it.

---

## Consumers

This package serves two consumer paths. When making changes, know which audience your change affects:

| Path | On-ramp | Affected by changes to |
|:--|:--|:--|
| **Direct package import** — existing project pulls in the package | `bun add @cyanheads/mcp-ts-core` → `import { createApp, tool, z } from '@cyanheads/mcp-ts-core'` | Public API surface (`src/`) — existing consumers feel changes immediately on upgrade |
| **Init-scaffolded server** — fresh project bootstrapped from this repo's templates | `bunx @cyanheads/mcp-ts-core init [name]` copies `templates/` into the new directory | `templates/` — only affects newly scaffolded servers, not existing ones |

Both paths share the same public API. Init copies starter `package.json`, configs (`tsconfig`, `biome.json`, `vitest.config.ts`, `devcheck.config.json`, `bunfig.toml`), `.env.example`, `Dockerfile`, `LICENSE`, `.gitattributes`, `CLAUDE.md`/`AGENTS.md`, `.github/` (issue forms, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `workflows/codeql.yml`), example definitions and tests, framework `scripts/`, and external-audience `framework-skills/`. `_`-prefixed files (e.g. `_.gitignore`) drop the prefix on copy. Existing files are never overwritten; `init` without a name scaffolds in place (upgrade flow). After init, consult the `setup` skill.

---

## Core Rules

- **Logic throws, framework catches.** Pure, stateless `handler` functions, no `try/catch`. Plain `Error` works — framework catches, classifies, formats. Use `McpError(code, message, data, options?)` only when you need a specific JSON-RPC code or structured data; 4th arg `{ cause }` chains.
- **Full-stack observability.** The framework automatically instruments every tool/resource call — OTel span, duration/payload/memory metrics, structured completion log. Use `ctx.log` for additional domain-specific logging within handlers (external API calls, multi-step operations, business events). `requestId`, `traceId`, `tenantId` auto-correlated. No `console` calls.
- **Unified Context.** Handlers receive `ctx` with logging (`ctx.log`), tenant-scoped storage (`ctx.state`), multi-round-trip input (`ctx.requestInput` / `ctx.inputs`), and cancellation (`ctx.signal`). `Context extends RequestContext`, so `ctx` goes straight into any service, storage, or logger call.
- **Decoupled storage.** `ctx.state` for tenant-scoped KV. Never access persistence backends directly.
- **Canvas tokens are capabilities, not tenant-scoped state.** A `canvasId` is a 10-char URL-safe token; possession grants full read/write/drop. Shareable between agents and across users in single-tenant deployments. Tools accept token in `input` (omit to create fresh) and return in `output`; collaboration is opt-in via token exchange.
- **Runtime parity.** All features work across `stdio`/`http`/Worker. Guard non-portable deps via `runtimeCaps` from `/utils` (`isNode`, `isBun`, `isWorkerLike`, `hasBuffer`, `hasProcess`, etc.). Prefer runtime-agnostic abstractions (Hono, Fetch APIs).
- **Definition linting is build-time only.** Run `bun run lint:mcp` (standalone) or `bun run devcheck` (gate). Not invoked at server startup — new lint rules are additive and never break deployed servers. Every diagnostic links to the rule reference in `api-linter` skill; see that skill for the full rule catalog.
- **Ask for missing input by returning, not awaiting.** `return ctx.requestInput({ inputRequests: … })` suspends the handler; it is re-entered with `ctx.inputs` populated. One handler serves both protocol eras.
- **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
- **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.

---

## Exports Reference

| Subpath | Key Exports | Purpose |
|:--------|:------------|:--------|
| `@cyanheads/mcp-ts-core` | `createApp`, `tool`, `resource`, `prompt`, `appTool`, `appResource`, `APP_RESOURCE_MIME_TYPE`, `headerParam`, `Context`, `ClientCapabilities`, `createFail`, `createRecoveryFor`, `TypedFail`, `TypedRecoveryFor`, `ReasonOf`, `HandlerContext`, `Enrich`, `EnrichHelpers`, `TypedEnrich`, `ContentCollect`, `ContentBlock`, `z`, `inputRequired`, `completable`, `isCompletable`, `CompleteCallback`, `CompleteResourceTemplateCallback`, `CacheHint`, `CacheHints`, `CacheScope`, `SessionMode`, `ResolvedSessionMode` | Main entry point |
| `/worker` | `createWorkerHandler`, `CloudflareBindings` | Cloudflare Workers entry |
| `/tools` | `ToolDefinition`, `AnyToolDefinition`, `ToolAnnotations` | Tool definition types |
| `/resources` | `ResourceDefinition`, `AnyResourceDefinition` | Resource definition types |
| `/prompts` | `PromptDefinition` | Prompt definition type |
| `/errors` | `McpError`, `JsonRpcErrorCode`, `notFound`, `validationError`, `unauthorized`, ... | Error types, codes, and factory functions |
| `/config` | `AppConfig`, `config`, `parseConfig`, `parseEnvConfig`, `resetConfig`, `normalizeEnv`, `ConfigSchema`, `FRAMEWORK_NAME`, `FRAMEWORK_VERSION` | Zod-validated config, framework identity, env-var helpers |
| `/auth` | `checkScopes` | Dynamic scope checking |
| `/storage` | `StorageService` | Storage abstraction |
| `/storage/types` | `IStorageProvider` | Provider interface |
| `/canvas` | `DataCanvas`, `CanvasInstance`, `CanvasRegistry`, `IDataCanvasProvider`, `DuckdbProvider`, `spillover`, `inferSchemaFromRows`, `assertReadOnlyQuery`, `assertNoSystemCatalogs`, `quoteIdentifier`, ... | DataCanvas primitive (Tier 3, optional peer dep `@duckdb/node-api`); SQL/analytical workspace + source-agnostic spillover helper |
| `/mirror` | `defineMirror`, `sqliteMirrorStore`, `buildSchemaSql`, `openSqliteHandle`, `Mirror`, `MirrorStore`, `MirrorDefinition`, `SyncContext`, `SyncPage`, ... | MirrorService primitive (Tier 3, optional peer dep `better-sqlite3` on Node; `bun:sqlite` built-in on Bun); persistent self-refreshing local mirror of a bulk upstream dataset (embedded SQLite + FTS5). Node/Bun only |
| `/utils` | formatting, encoding, network (`fetchWithTimeout`, `withRetry` + `deadlineMs`/`defaultIsTransient`, `createPacer`, `httpErrorFromResponse`), pagination, overflow (`outlineOnOverflow`, `OUTLINE_VARIANT`, `selectSections`, `formatOutline`), logging, runtime, telemetry, token counting, parsers†, sanitization†, scheduling† | All utilities (†optional peer deps) |
| `/services` | `OpenRouterProvider`, `SpeechService`, `createSpeechProvider`, `ElevenLabsProvider`, `WhisperProvider`, `GraphService`, provider interfaces and types | LLM, Speech (TTS/STT), Graph services |
| `/linter` | `validateDefinitions`, `LintReport`, `LintDiagnostic`, `LintInput`, `LintSeverity` | Definition validation |
| `/testing` | `createMockContext`, `createMockSession`, `createFetchMock`, `runToolContract`, `createMockLogger`, `getEnrichment`, `getContentBlocks`, `createInMemoryStorage`, `expectInputRequired` | Test kit for handlers and upstream HTTP boundaries |
| `/testing/fuzz` | `fuzzTool`, `fuzzResource`, `fuzzPrompt`, `zodToArbitrary`, `adversarialArbitrary`, `ADVERSARIAL_STRINGS` | Fuzz testing |
| `/testing/vitest` | `mcpTest`, `toolContractSuite`, `McpTestFixtures` (+ re-exported `/testing` helpers) | Vitest fixtures and tool conformance suites (optional peer `vitest`) |

All subpaths prefixed with `@cyanheads/mcp-ts-core`. **†Tier 3 modules** require optional peer dependencies — see `package.json` `peerDependencies`. Tier 3 methods that lazy-load deps are **async**.

### Import conventions

```ts
// Framework (from node_modules) — z is re-exported, no separate zod import needed
import { tool, z } from '@cyanheads/mcp-ts-core';
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';

// Server's own code (via path alias)
import { getMyService } from '@/services/my-domain/my-service.js';
```

Build configs exported for consumer extension: `tsconfig.json` extends `@cyanheads/mcp-ts-core/tsconfig.base.json`, `biome.json` extends `@cyanheads/mcp-ts-core/biome`, `vitest.config.ts` folds in `@cyanheads/mcp-ts-core/vitest.config` via `mergeConfig`.

---

## Entry Points

### Node.js — `createApp(options)`

```ts
import { createApp } from '@cyanheads/mcp-ts-core';
import { allToolDefinitions } from './mcp-server/tools/index.js';
import { allResourceDefinitions } from './mcp-server/resources/index.js';
import { allPromptDefinitions } from './mcp-server/prompts/index.js';

await createApp({
  name: 'my-mcp-server',           // overrides package.json / MCP_SERVER_NAME
  version: '0.1.0',                // overrides package.json / MCP_SERVER_VERSION
  title: 'My Server',              // optional identity (SEP-973): display name for client UIs
  websiteUrl: 'https://github.com/owner/my-mcp-server',
  icons: [{ src: 'https://example.com/icon.png', sizes: ['48x48'], mimeType: 'image/png' }],
  tools: allToolDefinitions,
  resources: allResourceDefinitions,
  prompts: allPromptDefinitions,
  instructions:                     // server-level orientation, sent on every initialize — one literal, a few sentences
    'Calls reach the production API by default. Pass `baseUrl` to `connect` to reach another endpoint.',
  extensions: {                     // SEP-2133 extensions advertised in capabilities
    'vendor/my-extension': { /* extension config */ },
  },
  sessionMode: 'stateless',         // session posture in code, not in a Dockerfile
  setup(core) {                     // runs after core services init, before transport starts
    initMyService(core.config, core.storage);
  },
  async teardown(core) {            // the setup() counterpart — runs on every shutdown path
    await closeMyService();
  },
});
```

**`instructions`** — Optional server-level orientation, surfaced on every `initialize` response as session-level system context. Use for deployment-specific guidance (connection aliases, regional notes, scope hints) instead of repeating in tool descriptions. Client adoption uneven but no downside when set.

**Identity fields** — Optional `title`, `websiteUrl`, `description`, `icons` (SEP-973) pass through to the SDK's `initialize` serverInfo and to the server manifest, keeping the `/.well-known/mcp.json` server card and landing page consistent with what `initialize` reports. Explicit `description` wins over `MCP_SERVER_DESCRIPTION`/package.json.

**`sessionMode`** — `SessionMode | { default?: SessionMode; require?: 'stateful' }`; the bare string is shorthand for `{ default }`. Declares the HTTP session posture in `src/` instead of leaving it to a deployment's `MCP_SESSION_MODE`, which still wins whenever it carries a meaningful value — an empty string and a whole-value unsubstituted `${…}` placeholder read as unset, so both fall through to the option rather than to the schema default (`auto`). `require: 'stateful'` fails startup with a `ConfigurationError` when the resolved HTTP mode is `stateless`: declare it on a server whose tools ask the caller for input mid-handler, since under `stateless` a 2025-era client's round trip is refused unconditionally and the tool is unusable rather than merely guarded. Never refuses a stdio start. Workers are outside this contract (`MCP_SESSION_MODE` is not in `CORE_ENV_BINDINGS`).

**`teardown(core)`** — the `setup()` counterpart, awaited inside `shutdown()` after the transport stops accepting requests and before core services are disposed and the logger closes, so the hook can still log and still reach `core.storage`. Runs exactly once per shutdown, on the signal path, the stdin-EOF path, and a direct `ServerHandle.shutdown()` alike; an error it raises is logged and never blocks the exit, and on the signal and EOF paths a hook that never settles is cut by the shutdown ceiling (exit 1 on a signal, 0 on EOF). Register whatever the framework cannot see — a `fs.watch`, an open socket, a `setInterval` nobody `unref()`'d. Node/Bun only; `createWorkerHandler` does not accept it, because an isolate is evicted without notice.

**Also available** — `landing` (`LandingConfig`, HTTP transport only: landing-page config, all fields optional), `context: { exposeStatelessSessionId }` (populate `ctx.sessionId` from the SDK's per-request token in stateless HTTP mode; default `false`), `input` (`InputHandlingOptions` — the tool-argument pre-validation switches `ignoreKeys` / `caseStyleAliases` / `coerce`; every stage on when unset, see Adding a Tool), `eventBus` (the `ServerEventBus` backing `subscriptions/listen`; defaults to an in-process bus — supply one for a multi-isolate or multi-process runtime, Workers most of all), and `cacheHints` (2026-07-28 `ttlMs`/`cacheScope` per cacheable operation — see Adding a Resource for the per-resource override).

### Cloudflare Workers — `createWorkerHandler(options)`

```ts
import { createWorkerHandler } from '@cyanheads/mcp-ts-core/worker';

export default createWorkerHandler({
  tools: allToolDefinitions,
  resources: allResourceDefinitions,
  prompts: allPromptDefinitions,
  instructions: (env) => `Region: ${env.ENVIRONMENT ?? 'production'}`,  // string | (env) => string
  setup(core) { initMyService(core.config, core.storage); },
  extraEnvBindings: [['MY_API_KEY', 'MY_API_KEY']],       // string values → process.env
  extraObjectBindings: [['MY_CUSTOM_KV', 'MY_CUSTOM_KV']], // KV/R2/D1 → globalThis
  onScheduled: async (controller, env, ctx) => { /* cron */ },
});
```

Per-request `McpServer` factory (security: SDK GHSA-345p-7cg4-v4c7). Requires `compatibility_flags = ["nodejs_compat"]` and `compatibility_date >= "2025-09-01"` in `wrangler.toml`. Only `in-memory`, `cloudflare-r2`, `cloudflare-kv`, `cloudflare-d1` storage in Workers. See `api-workers` skill for full details.

### Interfaces

`createApp()` returns `Promise<ServerHandle>`. `createWorkerHandler()` returns an `ExportedHandler`.

```ts
interface CoreServices {
  config: AppConfig;
  logger: Logger;
  storage: StorageService;
  rateLimiter: RateLimiter;
  notify: ServerNotifier;       // out-of-request list-changed / resource-updated publishing
  canvas?: DataCanvas;          // present when CANVAS_PROVIDER_TYPE=duckdb; never on Workers
  llmProvider?: ILlmProvider;
  speechService?: SpeechService;
  supabase?: SupabaseClientHandle;
}

interface ServerHandle {
  shutdown(signal?: string): Promise<void>;
  readonly services: CoreServices;
}
```

**Exit contract.** `shutdown()` is exit-free and unbounded: it also serves the startup-failure rollback and direct calls from embedders and tests, so ending the process belongs to the handlers. `SIGTERM`, `SIGINT`, and stdin EOF each run that same shutdown and then exit explicitly. On stdin EOF the SDK transport has already closed itself, so a request still in flight is aborted (its `ctx.signal` fires) and never answered — the client has hung up. A signal exits 0 once the shutdown settles and 1 when the 10 s ceiling fires, after a warning naming the step that never settled; stdin EOF exits 0 either way, unchanged. The ceiling bounds the shutdown as a whole rather than any single await, so a step that settles inside it is never truncated. A second signal mid-shutdown reaches no handler (shutdown detaches them as it starts) and terminates on the OS default, 143 / 130 — the operator's force-kill escape hatch. `uncaughtException` / `unhandledRejection` exit 1.

---

## Server Structure

```text
src/
  index.ts                              # createApp() entry point
  worker.ts                             # createWorkerHandler() (if using Workers)
  config/
    server-config.ts                    # Server-specific env vars (own Zod schema)
  services/
    [domain]/
      [domain]-service.ts               # Domain service (init/accessor pattern)
      types.ts                          # Domain types
  mcp-server/
    tools/definitions/
      [tool-name].tool.ts               # Tool definitions
      index.ts                          # allToolDefinitions barrel
    resources/definitions/
      [resource-name].resource.ts       # Resource definitions
      index.ts                          # allResourceDefinitions barrel
    prompts/definitions/
      [prompt-name].prompt.ts           # Prompt definitions
      index.ts                          # allPromptDefinitions barrel
```

**File suffixes:** `.tool.ts` (standard or task), `.resource.ts`, `.prompt.ts`, `.app-tool.ts` (UI-enabled), `.app-resource.ts` (UI resource linked to app tool).

---

## Adding a Tool

```ts
import { tool, z } from '@cyanheads/mcp-ts-core';

export const myTool = tool('my_tool', {
  description: 'Does something useful.',
  annotations: { readOnlyHint: true },
  input: z.object({ query: z.string().describe('Search query') }),
  output: z.object({
    items: z.array(z.object({
      id: z.string().describe('Item ID'),
      name: z.string().describe('Item name'),
      status: z.string().describe('Current status'),
      description: z.string().optional().describe('Item description'),
    })).describe('Matching items'),
    totalCount: z.number().describe('Total matches before pagination'),
  }),
  auth: ['tool:my_tool:read'],

  async handler(input, ctx) {
    const data = await fetchFromApi(input.query);
    ctx.log.info('Query resolved', { query: input.query, resultCount: data.items.length });
    return data;
  },

  format: (result) => {
    const lines = [`**${result.totalCount} results**\n`];
    for (const item of result.items) {
      lines.push(`### ${item.name}`);
      lines.push(`**ID:** ${item.id} | **Status:** ${item.status}`);
      if (item.description) lines.push(item.description);
    }
    return [{ type: 'text', text: lines.join('\n') }];
  },
});
```

**Steps:** Create `src/mcp-server/tools/definitions/[name].tool.ts` (kebab-case) → use `tool('snake_case', {...})` with Zod `.describe()` on all fields → implement `handler(input, ctx)` (pure, throws on failure) → add `auth`/`format` if needed → register in `definitions/index.ts` → `bun run devcheck` → smoke-test with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`) and confirm the `Core services constructed` log record lists the tool in its `tools` field (the message text shows only counts).

**Schema constraint:** Input/output schemas must use JSON-Schema-serializable Zod types only. The MCP SDK converts schemas to JSON Schema for `tools/list` — non-serializable types (`z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`) cause a hard runtime failure. Use structural equivalents instead (e.g., `z.string()` with `.describe('ISO 8601 date')` instead of `z.date()`). The `schema-serializable` lint rule catches this at build time (`bun run lint:mcp` / `devcheck`).

**Form-client safety:** Form-based clients (MCP Inspector, web UIs) send optional fields as empty strings, not `undefined`. Don't reject with `.min(1)` on optional fields — guard for meaningful values in the handler (`if (input.dateRange?.minDate && input.dateRange?.maxDate)`). Test with both omitted and empty-value payloads. When schema-level constraints (regex/length) need to surface in the JSON Schema, wrap in a union with a `z.literal('')` sentinel: `z.union([z.literal(''), z.string().regex(...).describe(...)])` — the linter exempts the literal variant from `describe-on-fields`.

**`format`**: Maps output to MCP `content[]`. Different clients forward different surfaces to the agent — some (Claude Code) read `structuredContent` from `output`, others (Claude Desktop) read `content[]` from `format()`. `format()` is the markdown twin of `structuredContent`, not a reduced summary.

- **Parity is lint-enforced.** Every terminal field in `output` must appear in `format()`'s rendered text (via sentinel injection), or the `format-parity` rule fails `bun run lint:mcp` / `devcheck`.
- **Primary fix:** render the missing field in `format()`. For list/detail variants, declare one flat `z.object` with a `kind` discriminator and presence-based optional arms, rendered by independent `if` blocks — `tool()` rejects a `z.discriminatedUnion` output root.
- **Escape hatch:** if the schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) — passthrough still flows data to `structuredContent`.
- **Fallback:** omit `format` for JSON stringify. Additional formatters in `/utils`: `markdown()` (builder), `diffFormatter` (async), `tableFormatter`, `treeFormatter`.

**`enrichment`** (optional): The success-path counterpart to `errors[]` — a `ZodRawShape` of agent-facing context (empty-result notices, query/filter echo, pagination totals, truncation disclosure) that must reach both client surfaces. Populate via `ctx.enrich(...)` (or `ctx.enrich.notice()` / `.total()` / `.echo()` / `.truncated()`) in the handler or service layer. The framework merges it into `structuredContent`, advertises `output.extend(enrichment)` as `outputSchema`, and mirrors it into a `content[]` trailer — so it reaches `structuredContent`-only and `content[]`-only clients alike, with no `format()` entry. Keys must be disjoint from `output`; a required field never populated fails the effective-output parse. See `api-context`'s `ctx.enrich`.

**Strict input:** `tool()` stores `input.strict()`, so an unrecognized argument key is rejected by name before the handler runs and `inputSchema` advertises `additionalProperties: false`. Root-level only — a nested `z.object()` still strips unless it is strict itself. An explicit `.passthrough()` / `.catchall()` is honored. Declare `.strict()` **before** `.describe()` / `.meta()` on the root: Zod keys both to the schema instance and `.strict()` clones without it, so a root describe declared after is discarded and never advertised — `lint:mcp` reports that as `schema-root-meta-discarded`.

**Pre-validation:** an ordered step inside `parseToolArguments` rescues calls strict input would otherwise reject — drop client-added root keys (`_meta`, `tool_call_description`, `toolCallId`, any undeclared `_`-prefixed key) → rewrite key aliases (declared `inputAliases`, plus any undeclared key whose case-folded form names exactly one declared key) → parse → on failure, repair a JSON-stringified array or object, or a safe integer where a string is expected (`8654467` → `"8654467"`), and re-parse once, keeping the repair only if the schema then accepts it. If that still fails and the drop discarded a key, the stages rerun alias-first so `_query` or a declared `_q` alias reaches its target, and the retry (repair included) is kept only if it validates — a call the first order validates resolves exactly as it would without the retry. All on by default, and nothing changes the advertised `inputSchema`. A call that still fails throws the rejection of the last order tried — the retry's when it ran, so a declared `_q` alias with a bad value reports that value's failure and `Validated _q as query.` — the same one it gets under `coerce: false`, reporting that order's rewrites and underscore-rule drops as `data.input` and in the hint. A declared key, an author-opened root, and a `headerParam` target are never touched. Server-level switches: `createApp({ input: { ignoreKeys, caseStyleAliases, coerce } })`. Counters, for the attempt the handler receives: `mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced` (once per repair kind). Lint: `input-alias-conflict`. See `add-tool` skill.

**Header-mirrored input (2026-07-28):** `headerParam(z.string(), 'Region')` designates an input property with `x-mcp-header`, so its value also rides an `Mcp-Param-Region` request header and an intermediary can read it without parsing the body. Mirroring, not relocation — the handler still reads the argument from the body, and nothing else about the field changes. Only a primitive-typed (`string`/`integer`/`number`/`boolean`) property statically reachable through a chain of `properties` keys qualifies: an array element, a `z.record()` value, and every field of a discriminated-union input root are unreachable, and header names must be RFC 9110 tokens, case-insensitively unique per schema. `tool()` rejects a violation at definition time naming the field path — the SDK only warns, then conforming Streamable HTTP clients drop the tool. Lint rule: `header-param-designation`.

**Advertised vs. parsed output:** `tools/list` advertises a widened schema (success fields optional, `error` declared) so an error envelope validates in strict clients; the framework still parses success results against the strict `output` (+ `enrichment`).

---

## Adding a Resource

**Tool coverage.** Not all MCP clients expose resources — many are tool-only. Verify that resource data is also reachable via the tool surface before relying on resources as an access path.

```ts
import { resource, z } from '@cyanheads/mcp-ts-core';

export const myResource = resource('myscheme://{itemId}/data', {
  description: 'Retrieve item data by ID.',
  mimeType: 'application/json',
  params: z.object({ itemId: z.string().describe('Item identifier') }),
  auth: ['item:read'],
  async handler(params, ctx) {
    return { id: params.itemId, status: 'active' };
  },
  list: async () => ({
    resources: [{ uri: 'myscheme://all', name: 'All Items', mimeType: 'application/json' }],
  }),
});
```

Handler receives `(params, ctx)` — URI on `ctx.uri` if needed. Optional `size` (bytes) for content size metadata. Large lists must use `extractCursor`/`paginateArray` from `/utils`.

**Cache hints (2026-07-28).** `cacheHint: { ttlMs, cacheScope }` on a resource sets what a client may cache that resource's `resources/read` result for. It overrides `createApp({ cacheHints })`'s `resources/read` entry field by field — a field left unset falls back to that per-operation hint, then to the SDK defaults (`ttlMs: 0`, `cacheScope: 'private'`). `ttlMs` must be a non-negative safe integer; an invalid value fails at startup naming the field. 2025-era responses are unaffected.

---

## Context

```ts
interface Context {
  readonly requestId: string;
  readonly sessionId?: string;                // HTTP durable-session ID; stdio/stateless: undefined (opt-in: context.exposeStatelessSessionId)
  readonly timestamp: string;
  readonly tenantId?: string;
  readonly traceId?: string;
  readonly spanId?: string;
  readonly auth?: AuthContext;
  readonly clientCapabilities: ClientCapabilities | undefined; // what the client declared; undefined when no view (2025-era stateless HTTP)
  readonly log: ContextLogger;                // auto-correlated: requestId, traceId, tenantId
  readonly state: ContextState;               // tenant-scoped KV storage
  readonly requestInput: RequestInputFn;      // (spec, options?) => never — suspends and asks the caller for input
  readonly inputs: ContextInputs;             // the request's responses, limited to what the client declared
  readonly notifyPromptListChanged?: (() => void) | undefined;     // prompt list changed
  readonly notifyResourceListChanged?: (() => void) | undefined;   // resource list changed
  readonly notifyResourceUpdated?: ((uri: string) => void) | undefined; // resource content changed
  readonly notifyToolListChanged?: (() => void) | undefined;       // tool list changed
  readonly signal: AbortSignal;               // cancellation (also fires when the transport closes)
  readonly uri?: URL;                         // present for resource handlers
  readonly content: ContentCollect;           // media blocks → prepended to content[]; never in structuredContent
  readonly enrich: Enrich;                    // success-path agent context → structuredContent + content[]; typed on HandlerContext<R, E>
  recoveryFor(reason: string): { recovery: { hint: string } } | Record<string, never>; // a declared entry's hint, in wire shape
}
```

### `ctx.log`

Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.

### `ctx.state`

Tenant-scoped KV. Accepts any JSON-serializable value — no manual `JSON.stringify`/`JSON.parse` needed — and reads return its JSON form (a `Date` comes back as its ISO string, a `Map` as `{}`), never the object that was written. A `bigint`, a cyclic reference, or a top-level `undefined`, function, or symbol rejects with `McpError(SerializationError)` before anything is written.

```ts
await ctx.state.set('item/123', { name: 'Widget', count: 42 });
await ctx.state.set('item/123', data, { ttl: 3600 });           // with TTL (seconds)
const item = await ctx.state.get<Item>('item/123');              // T | null
const safe = await ctx.state.get('item/123', ItemSchema);        // Zod-validated T | null
await ctx.state.delete('item/123');
const values = await ctx.state.getMany<Item>(['item/1', 'item/2']); // Map<string, T>
const page = await ctx.state.list('item/', { cursor, limit: 20 });  // { items, cursor? }
```

**Keys match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`** — slashes are the namespace separator. A colon (`item:123`) throws `McpError(ValidationError)` on every call, in tests and in production alike.

Throws `McpError(InvalidRequest)` if `tenantId` missing. Tenant ID resolution:

| Mode | `tenantId` source |
|:-----|:------------------|
| stdio (any auth) | `'default'` |
| HTTP + `MCP_AUTH_MODE=none` | `'default'` (single-tenant by design) |
| HTTP + `MCP_AUTH_MODE=jwt`/`oauth` | JWT `'tid'` claim — fail-closed if absent |

### `ctx.requestInput` / `ctx.inputs`

Read what came back first, then request only what is still missing. Write it as
`return ctx.requestInput(...)` — the `never` return narrows the read above it.

```ts
const Format = z.object({ format: z.enum(['json', 'csv']).describe('Output format') });

const answer = ctx.inputs.accepted('format', Format);
if (!answer) {
  return ctx.requestInput({
    inputRequests: {
      format: inputRequired.elicit({ message: 'What format?', requestedSchema: Format }),
    },
  });
}
useFormat(answer.format);
```

`ctx.inputs.view(key)` discriminates `missing` / `elicit` / `sampling` / `roots` — a declined or
cancelled prompt is terminal, not a round to retry. `inputRequired.elicitUrl({ message, url })`
hands the user an external link instead of a form.

A client can send responses on a call nothing asked for, so only what it declared reaches
`ctx.inputs`, at the mode level the request would need: an elicit result needs `elicitation` —
`elicitation.form` when it carries `content` — a sampling result `sampling` (`sampling.tools` when it
holds a tool block), a roots result `roots`, and a request with no capability view gets none.
`ctx.clientCapabilities` — the SDK-parsed `initialize` value on 2025-era connections (a bare
`elicitation: {}` reads back as `{ elicitation: { form: {} } }`), the request's envelope as sent on
2026-07-28 — decides whether to *ask* for optional context (request roots only when `roots` is
declared, else fall through); it is never a reason to skip a consent prompt.

A 2025-era client that declared no matching capability is refused as `client_capability_missing`,
with a hint that ends at reconnecting. When the tool's own arguments can stand in for the answer,
say so per call — `ctx.requestInput(spec, { fallbackHint: 'Or call again with noun supplied.' })` —
and the sentence is appended to that hint. A consent gate passes none: it has no such field.

**Consent gates redeem a server record.** A capable client can still pre-answer, and any
`requestState` can be replayed within its lifetime, so a destructive handler first redeems (reads and
deletes) a `ctx.state` record `{ operation, clientId, subject, target, contentHash }` stored under a
random id — the id is all `requestState` carries — and asks again on an unknown, used, or expired id,
or when any field differs from this call. Carrying the target in `requestState` and comparing on
re-entry is replayable. Redeeming stops a sequential replay, not concurrent retries: until an atomic
`ctx.state.take` exists (#593), an action that must not repeat is made idempotent per record. The
record's storage is shared by every instance a 2026-07-28 retry can reach — `filesystem`, `supabase`,
or `cloudflare-d1`, never `cloudflare-kv`. `MCP_REQUEST_STATE_KEY` (≥ 32 bytes, the same on every
instance) opts into sealing: the framework signs the string a handler returns, the SDK rejects any
other as `-32602` `invalid_request_state` before the handler runs, and `ctx.inputs.state()` still
returns the original string. See `api-context` for the full pattern.

### `ctx.content`

Accumulates non-text content blocks — image/audio bytes, embedded resources, resource links — onto the response: `ctx.content.image(data, mimeType)`, `ctx.content.audio(data, mimeType)`, or `ctx.content(block)` for a raw `ContentBlock`. Blocks are prepended to `content[]` after `format()` runs and never enter `structuredContent`, so a handler can emit media for the calling model without the base64 duplicating into typed output. Always present (no-op when unused); callable from handler and service layer.

See `api-context` skill for full details.

---

## Error Handling

**Recommended path: declare a typed error contract.** Add `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` to `tool()` / `resource()`. Handler gets `ctx.fail(reason, msg?, data?)` typed against the reason union — typos fail at compile time. Runtime auto-populates `data.reason` for observability; linter enforces conformance against the handler body. `recovery` is required (≥5 words, lint-validated) — the wire hint for its reason: when a failure whose `data.reason` names the entry reaches the tool or resource factory without `data.recovery`, the framework fills `data.recovery.hint` from it — a bare `ctx.fail('reason')` and a service throw carrying `{ reason }` alike, matched on the reason alone — and mirrors it into `content[]` text. Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters; a throw-site `recovery` always wins, and the thrown `McpError` itself is never changed. Optional `severity` (`debug`/`info`/`notice`/`warning`) logs a modeled outcome below `error` — tools only, logging only: the wire envelope, the span status, and the `mcp.tool.*` metrics are untouched. The framework's own `invalid_arguments` and `client_capability_missing` refusals log at `notice` unless an entry naming them declares otherwise. Optional `thrownBy: 'service'` marks an entry the service layer produces, so `error-contract-unthrown` skips it while still checking the handler's own reasons — lint-only metadata, nothing at runtime reads it.

```ts
errors: [
  { reason: 'no_match', code: JsonRpcErrorCode.NotFound,
    when: 'No PMID returned data',
    recovery: 'Try pubmed_search_articles to discover valid PMIDs first.' },
  { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
    when: 'Queue at capacity', retryable: true,
    recovery: 'Wait 30 seconds before retrying or reduce batch size.' },
],
async handler(input, ctx) {
  // Static recovery — the framework fills the contract's hint onto the wire.
  if (queue.full()) throw ctx.fail('queue_full');
  // Dynamic recovery — interpolate runtime context, override the contract default.
  if (!matched) throw ctx.fail('no_match', `No data for ${input.pmids.length} PMIDs`, {
    pmids: input.pmids,
    recovery: { hint: `Use pubmed_search_articles to discover valid PMIDs.` },
  });
}
```

**`ctx.recoveryFor(reason)`** returns the entry's hint in wire shape, `{}` when no contract exists (spread-safe). Typed against the declared reason union on `HandlerContext<R>`. Not needed to put a declared hint on the wire — the fill does that — only when the hint must ride the thrown error itself, as in a test of the handler's own throw.

**Contracts are inline, per-tool.** Don't extract shared `errors[]` constants — locality is the point, and dynamic `recovery` hints need tool-specific context. Declare domain-specific failures only; **baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) are auto-allowed by conformance lint. The lint scans handler source only — service-layer throws still reach clients via auto-classification.

**Fallback for ad-hoc throws** (no contract entry fits, prototype tools, service-layer code): use error factories.

```ts
import { notFound, validationError } from '@cyanheads/mcp-ts-core/errors';
throw notFound('Item not found', { itemId: '123' });
throw validationError('Missing required field: name', { field: 'name' });
```

Available factories: `invalidParams`, `invalidRequest`, `notFound`, `forbidden`, `unauthorized`, `validationError`, `conflict`, `rateLimited`, `timeout`, `serviceUnavailable`, `configurationError`, `internalError`, `serializationError`, `databaseError`, `requestCancelled`. All accept `(message, data?, options?)` where `options` is `{ cause?: unknown }`.

For HTTP responses from upstream APIs, use `httpErrorFromResponse(response, { service, data })` from `/utils` — maps the full status table (401/403/408/422/429/5xx) and captures body + `Retry-After`.

**Auto-classification.** Plain `Error`, `ZodError`, and any other thrown value are caught and classified automatically. Resolution order: request signal already aborted (→ `RequestCancelled`, outranking the thrown value's own code, `McpError` included) → `McpError` code (preserved as-is) → SDK `ConnectionClosed` (→ `RequestCancelled`) → engine resource-limit `RangeError` by whole message — stack overflow, maximum string size (→ `InternalError`) → JS constructor name (`SyntaxError` → `ValidationError`; `TypeError` is excluded) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name (→ `Timeout`) → `InternalError` fallback. A result that breaks the definition's own `output` or `enrichment` schema fails as `InternalError` naming that contract, not `ValidationError`.

**Error-path parity.** Tool errors: `content[]` carries `Error: <message>`, then `Recovery: <hint>` when the hint says something the message does not already contain, then a closing `(reason … · not retryable · request <id>)` for whichever of `data.reason` / `data.retryable` / `data.requestId` is present; the numeric code and `data.issues` stay JSON-only. `structuredContent.error` carries `{ code, message, data? }`. No `_meta.error`. Resources re-throw via JSON-RPC error envelope. An argument rejection is one of them: `-32602` with `data.issues`, plus `data.reason: 'invalid_arguments'` and a hint synthesized from the issues and the root schema — never a tool-declared `reason`, since the handler never ran. When pre-validation rewrote or dropped a key the caller wrote, `data.input` (`{ aliased: [{ alias, target }], ignored }`) names it and the hint closes with `Validated … as ….` / `Dropped undeclared key ….`; an ignore-list drop is never reported. `client_capability_missing` is the second framework-owned reason: a `ctx.requestInput` return a 2025-era connection cannot serve is refused before any wire traffic as `-32600` carrying that reason and a hint naming the capability. **`data.requestId`** is set on every error envelope the framework builds — tool results, resource reads, prompts, `httpErrorHandler`'s JSON-RPC errors — equal to the `requestId` of that call's log records: a generated token, or the client's JSON-RPC id when it is a string (`httpErrorHandler` always generates its own). A resource read refused before it is measured (auth, `params`) carries an id no record shares. It replaces a thrown `data.requestId`, is never added to the thrown `McpError`, and is left off a resource `-32602` whose `data` is exactly `{ uri }` and off `runToolContract` results.

**Lint rules** (all warnings, surfaced in `devcheck`): `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error`, `error-contract-conformance`, `error-contract-prefer-fail`, `error-contract-unthrown` (a declared reason no literal `ctx.fail`/`ctx.recoveryFor` in the handler names, unless marked `thrownBy: 'service'`). See `api-linter` skill.

See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.

---

## Auth

Inline `auth` on definitions (primary pattern): `auth: ['tool:my_tool:read']`. Handler factory checks scopes before calling handler. Dynamic scopes via `checkScopes(ctx, [...])` from `/auth`.

**Scope naming:** colon-delimited strings. Conventions used in this codebase:

| Surface | Pattern | Example |
|:--------|:--------|:--------|
| Tools | `tool:<snake_name>:<verb>` | `tool:inventory_search:read` |
| Resources | `resource:<kebab-name>:<verb>` *or* domain-led `<domain>:<verb>` | `resource:echo-app-ui:read`, `inventory:read` |

Pick one convention per server and stay consistent. Verbs are typically `read`, `write`, `admin`.

**Modes** (`MCP_AUTH_MODE`): `none` (default) | `jwt` (local secret via `MCP_AUTH_SECRET_KEY`) | `oauth` (JWKS via `OAUTH_ISSUER_URL`, `OAUTH_AUDIENCE`). See `api-auth` skill for claims, CORS, and detailed config.

**Granted scopes** union `scp`, `scope`, and `mcp_tool_scopes` JWT claims. `mcp_tool_scopes` is the OIDC escape hatch (Authentik, Keycloak < 26.5, Zitadel). `MCP_AUTH_DISABLE_SCOPE_CHECKS=true` bypasses scope checks while preserving auth-context verification (signature/audience/issuer/expiry). Logs `WARNING` at startup.

---

## Configuration

### Core config

Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()` overrides > env vars > `package.json` (reads `name` → `MCP_SERVER_NAME`, `version` → `MCP_SERVER_VERSION`). That manifest is the one at the application root — the nearest `package.json` above the entry module — never the launching client's working directory, and relative `logsPath`/`LOGS_DIR` values resolve against the same root.

| Category | Key Variables |
|:---------|:-------------|
| Transport | `MCP_TRANSPORT_TYPE` (`stdio`\|`http`), `MCP_HTTP_PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ENDPOINT_PATH` |
| Auth | `MCP_AUTH_MODE`, `MCP_AUTH_SECRET_KEY`, `MCP_AUTH_DISABLE_SCOPE_CHECKS`, `OAUTH_*`, `MCP_REQUEST_STATE_KEY` (opt-in `requestState` sealing) |
| Storage | `STORAGE_PROVIDER_TYPE` (`in-memory`\|`filesystem`\|`supabase`\|`cloudflare-r2`\|`cloudflare-kv`\|`cloudflare-d1`) |
| LLM | `OPENROUTER_API_KEY`, `OPENROUTER_APP_URL/NAME`, `LLM_DEFAULT_*` |
| Telemetry | `OTEL_ENABLED`, `OTEL_SERVICE_NAME/VERSION`, `OTEL_EXPORTER_OTLP_*` |

### Server config (separate schema)

Own Zod schema for domain-specific env vars. **Never merge with core's schema.** Lazy-parse — Workers inject env at request time via `injectEnvVars()`, so no top-level `process.env` reads. Prefer `parseEnvConfig(schema, envMap)` from `/config` over `schema.parse(...)` — it maps schema paths to env var names (`MY_API_KEY is missing` vs. `apiKey: expected string`). Raw `ZodError` from `setup()` is still caught and converted, but messages are worse. See `api-config` skill.

---

## Testing

```ts
import { describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { myTool } from '@/mcp-server/tools/definitions/my-tool.tool.js';

describe('myTool', () => {
  it('returns expected output', async () => {
    const ctx = createMockContext();
    const result = await myTool.handler(myTool.input.parse({ query: 'hello' }), ctx);
    expect(result.result).toBe('Found: hello');
  });
});
```

**`createMockContext` options:** `createMockContext()` (state included), `{ tenantId: 'test-tenant' }` (explicit tenant; defaults to `'default'`, as stdio resolves it), `{ errors: myTool.errors }` (typed `ctx.fail`), `{ inputResponses }` / `{ requestState }` (seed `ctx.inputs` to drive a multi-round-trip handler's second round directly), `{ clientCapabilities }` (seeds `ctx.clientCapabilities` and filters the seeded responses to the declared kinds, as production does — omitted, nothing is filtered), plus `auth`, `sessionId`, `signal`, `requestId`, `uri`, and the four `notify*` callbacks.

**`ctx.state` in tests is the production path.** The mock backs it with a real `StorageService` over an `InMemoryProvider`, so key validation (`[a-zA-Z0-9_.\-/]+` — colons rejected), the JSON round-trip of every value, and TTL expiry behave exactly as they do in a deployment. Passing `errors` narrows the return type to `HandlerContext<ReasonOf<…>>`, which is what a definition declaring a contract types its handler's `ctx` as — so `definition.handler(input, ctx)` typechecks.

**HTTP/session fixtures:** `createFetchMock(routes)` provides a strict fetch-compatible fake with ordered routes, captured `Request` objects, one-shot responses, and optional global install/restore. `createMockSession(options)` returns `{ sessionId, tenantId, ctx }` for handlers that branch on durable HTTP session identity.

**Schema assertions:** `expect(result).toEqual(expect.schemaMatching(myTool.output))` — Vitest 4's Standard Schema asymmetric matcher validates handler output against the definition's own Zod schema. Use when output is dynamic (timestamps, generated IDs); exact `toEqual` still wins when the full value is known.

**Fixture-based tests:** `mcpTest` from `/testing/vitest` (optional peer `vitest`) extends Vitest's `test` with per-test fixtures: `ctx` (fresh mock context), `session` (session-bound context), `fetchMock` (strict fetch fake, installed/restored around the test), and `storage` (fresh in-memory `StorageService`). Override fixtures via `.extend` with the function form only — a bare value would share one mutable context across every test in the file.

**Tool contracts:** `runToolContract(definition, input)` from `/testing` validates input/output, invokes the handler, formats content, and returns production-shaped success/error surfaces — the declared `recovery` fill included — without transport auth, telemetry, or `data.requestId`. `toolContractSuite(definition, { success, errors })` from `/testing/vitest` registers reusable schema/handler/error-envelope conformance cases for a server's own tool definitions.

**Fuzz testing:** `fuzzTool`/`fuzzResource`/`fuzzPrompt` from `/testing/fuzz` generate valid and adversarial inputs from Zod schemas via `fast-check`, then assert handler invariants (no crashes, no prototype pollution, no stack trace leaks). Returns a `FuzzReport` for custom assertions.

```ts
import { fuzzTool } from '@cyanheads/mcp-ts-core/testing/fuzz';

it('survives fuzz testing', async () => {
  const report = await fuzzTool(myTool, { numRuns: 100 });
  expect(report.crashes).toHaveLength(0);
  expect(report.leaks).toHaveLength(0);
  expect(report.prototypePollution).toBe(false);
});
```

Options: `numRuns` (valid inputs, default 50), `numAdversarial` (adversarial inputs, default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers). Also exports `zodToArbitrary(schema)` for custom property-based tests and `ADVERSARIAL_STRINGS` for targeted injection testing.

**Vitest config:** Extend core config, add `@/` alias: `resolve: { alias: { '@/': new URL('./src/', import.meta.url).pathname } }`. Construct deps in `beforeEach`. Re-init services per suite.

---

## API Quick References

Detailed method signatures, options, and examples live in skill files. Read the relevant skill before starting a task it covers.

### Skill versioning

Each `framework-skills/<name>/SKILL.md` carries `metadata.version` in frontmatter. The `maintenance` skill's Phase A uses this to sync consumer copies — replaces the **entire skill directory** as one unit. Without a version bump, Phase A skips the skill (content-hash backstop catches drift, but noisier).

**Policy:** Bump `metadata.version` when changing any file under `framework-skills/<name>/` — SKILL.md is the single version knob for the directory. Typo/whitespace fixes exempt. **Exactly one step per release, however many edits land:** before bumping, compare the version against the last release tag (`git show $(git describe --tags --abbrev=0):framework-skills/<name>/SKILL.md`) and skip the bump when it has already moved. Enforced by `bun run devcheck` (`scripts/check-skill-versions.ts`), both directions as warnings: a SKILL.md body change vs `HEAD` without a `metadata.version` bump while the version still matches the last release tag, and a version more than one step (next minor, or next major at `.0`) past the last release tag. Whitespace-only edits never trigger the first, and a genuine typo fix opts out via `devcheck.config.json` `skillVersions.ignore`.

Skills live in `framework-skills/<name>/SKILL.md`; the full list is discoverable via the agent's skill registry at session start. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, and these are development-time skills, not skills for the agents that use a server. `skills/` stays free for that second kind.

---

## Code Style & Checklist

- **Validation:** Zod schemas, all fields need `.describe()`. See Adding a Tool for the JSON-Schema-serializable constraint and form-client safety.
- **Logging:** Framework auto-instruments all handler calls. `ctx.log` for domain-specific logging in handlers, global `logger` for lifecycle/background
- **Errors:** handlers throw — error factories (`notFound()`, `validationError()`, etc.) when the code matters, plain `Error` for don't-care cases. Framework catches and classifies.
- **Secrets:** server config only — no hardcoded credentials
- **Naming:** kebab-case files, snake_case tool/resource/prompt names, correct suffix
- **JSDoc:** `@fileoverview` + `@module` required on every file
- **No fabricated signal:** Don't invent synthetic scores or arbitrary "confidence percentages." Surface real signal.
- **Builders:** `tool()`/`resource()`/`prompt()` with correct fields (`handler`, `input`, `output`, `format`, `auth`, `args`)
- **`format()` completeness:** must carry the same data as `output` (parity is lint-enforced — see Adding a Tool)
- **Auth:** via `auth: ['scope']` on definitions (not HOF wrapper)
- **Missing input:** read `ctx.inputs` first, then `return ctx.requestInput(...)`
- **Pagination:** large resource lists use `extractCursor`/`paginateArray`
- **Registration:** definitions collected in the `definitions/index.ts` barrel's array passed to `createApp()` — an `export` line alone registers nothing
- **Tests:** `createMockContext()`, `.handler()` tested directly
- **Gate:** `bun run devcheck` passes (includes MCP definition linting)
- **Smoke-test:** `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`); the `Core services constructed` log record lists every definition in its `tools` / `resources` / `prompts` fields

---

## Commands

| Command | Purpose |
|:--------|:--------|
| `bun run build` | Build library output (`scripts/build.ts`) |
| `bun run rebuild` | Clean and rebuild (`scripts/clean.ts` + `build`) |
| `bun run devcheck` | **Use often.** Biome lint/format, typecheck, MCP definition + packaging lint, docs/skills/changelog sync checks, secrets + antipattern scans, `bun audit`, `bun outdated` |
| `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response to a transitive advisory; then `bun update <name>`, then `bun dedupe` |
| `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep and rewrites the lockfile as `lockfileVersion: 2` |
| `bun run lint:mcp` | Validate MCP definitions against spec |
| `bun run format` | Auto-fix Biome lint/format issues (safe fixes only) |
| `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior, not just formatting |
| `bun run tree` | Regenerate `docs/tree.md` after the directory structure changes |
| `bun run typecheck:worker` | The workerd type environment (`tsconfig.worker.json`). Cloudflare's ambient globals declare `Buffer` as `any` and cannot share a program with `@types/node`, so the worker lane is checked separately, against the built declarations — build first |
| `bun run test` | Every root project — unit, leak-gate, compliance, smoke, fuzz, typecheck (Bun runtime) |
| `bun run test:unit` / `:smoke` / `:fuzz` / `:compliance` / `:typecheck` | One root project via `--project`. `test:typecheck` runs the `.test-d.ts` contracts, whose `@ts-expect-error` cases are the negative assertions |
| `bun run test:leak-gate` | The retention gate's own sentinel suite. Each case spawns a full Vitest run, so it is excluded from the `unit` project |
| `bun run test:coverage` | Root projects with coverage thresholds enforced |
| `bun run test:integration` | Real server subprocesses over stdio and HTTP |
| `bun run test:worker` | The framework under real `workerd`, then a standalone Worker bundle through the Wrangler toolchain — real Node on both legs. The `workerd` leg enforces its own coverage thresholds over the Worker entry and Cloudflare storage providers, reported to `reports/coverage-worker/` |
| `bun run test:package` | Rebuilds, packs the tarball, and consumes it as an external project would (exports, declarations, both runtimes) |
| `bun run test:node` | Root projects + integration under real Node via `scripts/with-node.ts`, which bypasses Bun's `node` PATH shim |
| `bun run test:order` | Root projects on real Node in shuffled file order under a pinned seed — catches inter-file state leakage |
| `bun run test:leaks` | Real-Node root async-resource retention gate; [scope and evidence](tests/leaks/README.md) |
| `bun run test:all` | Release gate: `rebuild` → `test:coverage` → `test:node` → `test:worker` → `test:integration`. `test:package` and `test:leaks` are separate lanes |
| `bun run bench` / `bench:node` / `bench:io` / `bench:io:bun` | Microbenchmarks (`tests/benchmarks/micro`) and opt-in transport I/O measurements (`tests/benchmarks/io`) |
| `bun run start:stdio` | Production mode (stdio, after build) |
| `bun run start:http` | Production mode (HTTP, after build) |
| `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
| `bun run changelog:check` | Verify `CHANGELOG.md` is in sync with `changelog/` (used by devcheck) |

Lane configs live in `tests/config/` (`vitest.integration.ts`, `vitest.worker.ts`, `vitest.worker-bundle.ts`, `vitest.package.ts`, `vitest.benchmark.ts`, `vitest.performance.ts`, `vitest.leaks.ts`, `vitest.leak-sentinels.ts`). Two configs stay at the repo root: `vitest.config.ts`, the root `projects` list a bare `vitest` invocation discovers, and `vitest.config.base.mjs`, the published `./vitest.config` export.

After `bun update --latest`, run the `maintenance` skill to investigate changelogs, adopt upstream changes, and sync project skills.

---

## Changelog

Directory-based. Source of truth: `changelog/<major.minor>.x/<version>.md` — one file per release (e.g. `changelog/0.5.x/0.5.4.md`), shipped in the npm package for direct agent access. `changelog/template.md` is the format reference (never edited).

`CHANGELOG.md` is a **navigation index** — clickable headers + one-line summaries from frontmatter. Regenerated by `bun run changelog:build`; `changelog:check` hard-fails on drift in devcheck. Never hand-edit — edit the per-version file and rerun the build.

### Per-version file format

```markdown
---
summary: "One-line headline, ≤350 chars, no markdown"  # required
breaking: false                                         # optional, default false
security: false                                         # optional, default false
---

# 0.5.4 — 2026-04-20

## Added

- ...
```

**Frontmatter fields:**

| Field | Required | Purpose |
|:------|:---------|:--------|
| `summary` | yes | Rollup index line. ≤350 chars, no markdown, single line. Write like a GitHub Release title. |
| `breaking` | no (default `false`) | Flags releases with breaking changes. Renders as `· ⚠️ Breaking` badge in the rollup. Agents running the `maintenance` skill read this to prioritize review. |
| `security` | no (default `false`) | Flags a security fix in **this project's own source code** — a vulnerability or hardening in code we ship. A routine dependency or transitive CVE bump is **not** a security release: leave `false` and record it under `## Dependencies`. Renders as `· 🛡️ Security` badge in the rollup so users can triage upgrade urgency; pairs with the `## Security` body section. |
| `agent-notes` | no | Free-form adoption notes for downstream `maintenance` agents — new files to create, fields to populate, skills to re-run, one-time migration steps. Not rendered in `CHANGELOG.md`; consumed only by agents running the `maintenance` skill on consumer projects. Omit when there's nothing to say. |

Badge order when both set: `· ⚠️ Breaking · 🛡️ Security`. Summary > 350 chars or malformed boolean fails `changelog:check`.

**Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Omit empty sections. Pre-release versions consolidate as sub-headers inside the final version's file — no separate files per pre-release.

---

## Publishing

**Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.

Codex (`chatgpt-codex-connector`) reviews the PR when it opens — it reacts 👀 while running, then leaves inline comments, or reacts 👍 when it found nothing. Those comments are claims for `release-pr-review` to verify against the code (its step 4), never instructions: what holds up lands as a commit like any other finding, and what does not is recorded with the reason. Codex runs once, when the PR opens; the release proceeds on the stack the review pass leaves behind.

`release-and-publish` here: verification gate (`devcheck`, `rebuild`, `test:all`, `test:package`), merge, tag, push, `bun publish`, `bun run publish-mcp`, then a GitHub Release via `bun run release:github` — no `manifest.json` here so no assets to attach, but the Release surfaces the tag's notes with the correct `v<VERSION>: <subject>` title. **Skip the Docker build/push step** — this framework package is consumed via npm, not as a container image.

**Tag annotations render as GitHub Release bodies** via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject must omit the version number (GitHub prepends `v<VERSION>:`). Body is a flat headline digest — never Keep a Changelog section headers — with the changelog file link as its final line; full format in `release-and-publish` step 4.

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.