@cyanheads/mcp-ts-core 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +12 -8
  3. package/biome.json +1 -1
  4. package/changelog/0.9.x/0.9.7.md +18 -0
  5. package/changelog/0.9.x/0.9.8.md +24 -0
  6. package/changelog/template.md +26 -0
  7. package/package.json +3 -4
  8. package/skills/add-app-tool/SKILL.md +6 -4
  9. package/skills/add-export/SKILL.md +10 -8
  10. package/skills/add-prompt/SKILL.md +15 -8
  11. package/skills/add-provider/SKILL.md +29 -12
  12. package/skills/add-resource/SKILL.md +20 -11
  13. package/skills/add-service/SKILL.md +15 -17
  14. package/skills/add-test/SKILL.md +50 -9
  15. package/skills/add-tool/SKILL.md +13 -6
  16. package/skills/api-auth/SKILL.md +3 -2
  17. package/skills/api-canvas/SKILL.md +43 -6
  18. package/skills/api-config/SKILL.md +6 -0
  19. package/skills/api-context/SKILL.md +9 -3
  20. package/skills/api-errors/SKILL.md +5 -5
  21. package/skills/api-linter/SKILL.md +32 -9
  22. package/skills/api-services/SKILL.md +1 -1
  23. package/skills/api-services/references/graph.md +1 -1
  24. package/skills/api-services/references/speech.md +1 -1
  25. package/skills/api-telemetry/SKILL.md +5 -5
  26. package/skills/api-testing/SKILL.md +9 -1
  27. package/skills/api-utils/SKILL.md +1 -1
  28. package/skills/api-workers/SKILL.md +12 -5
  29. package/skills/design-mcp-server/SKILL.md +20 -8
  30. package/skills/field-test/SKILL.md +9 -7
  31. package/skills/git-wrapup/SKILL.md +218 -0
  32. package/skills/maintenance/SKILL.md +8 -6
  33. package/skills/migrate-mcp-ts-template/SKILL.md +11 -7
  34. package/skills/multi-server-orchestration/SKILL.md +17 -5
  35. package/skills/multi-server-orchestration/references/greenfield-buildout.md +32 -2
  36. package/skills/multi-server-orchestration/references/maintenance-pass.md +11 -3
  37. package/skills/multi-server-orchestration/references/release-and-publish-pass.md +14 -25
  38. package/skills/multi-server-orchestration/references/wrapup-pass.md +15 -36
  39. package/skills/polish-docs-meta/SKILL.md +3 -1
  40. package/skills/polish-docs-meta/references/package-meta.md +1 -1
  41. package/skills/polish-docs-meta/references/readme.md +14 -1
  42. package/skills/release-and-publish/SKILL.md +20 -7
  43. package/skills/report-issue-framework/SKILL.md +5 -3
  44. package/skills/report-issue-local/SKILL.md +10 -5
  45. package/skills/setup/SKILL.md +13 -8
  46. package/skills/tool-defs-analysis/SKILL.md +6 -3
  47. package/templates/AGENTS.md +14 -6
  48. package/templates/CLAUDE.md +14 -6
  49. package/templates/changelog/template.md +26 -0
@@ -21,10 +21,10 @@ For the full `createMockContext` API and testing patterns, read:
21
21
 
22
22
  1. **Identify the target** — which tool, resource, or service needs tests
23
23
  2. **Read the source file** — understand the handler's logic, input/output schemas, error paths, and which `ctx` features it uses
24
- 3. **Create the test file** in the repo's existing test layout
24
+ 3. **Create the test file** in the repo's existing test layout — search for existing `*.test.ts` files to confirm whether tests are colocated with source or under a root `tests/` directory
25
25
  4. **Write test cases** covering happy path, error paths, and edge cases
26
26
  5. **Run `bun run test`** to verify
27
- 6. **Run `bun run devcheck`** to verify types
27
+ 6. **Run `bun run devcheck`** to verify lint, types, and MCP definitions
28
28
 
29
29
  ## Determining What to Test
30
30
 
@@ -41,6 +41,7 @@ Read the handler and identify:
41
41
  | **`ctx.fail` (typed contract)** | Definitions with `errors[]` need `fail` attached to the mock ctx — `createMockContext({ errors: myTool.errors })` does it for you. Assert on `data.reason` (stable per-contract entry), not just `code`. |
42
42
  | **`format` function** | Test separately if defined — it's pure, no ctx needed. Verify it renders the IDs and fields the model needs, not just a count or title. For projection-style tools, test non-default field selections. |
43
43
  | **Sparse upstream payloads** | For third-party API integrations, build a fixture with omitted fields. Assert normalized output still validates and `format()` preserves unknown values instead of inventing facts. |
44
+ | **Form-client payloads** | If handler has optional fields: test with empty-string inner values (form clients send `""` instead of `undefined`). Assert handler doesn't break or produce invalid output. |
44
45
  | **Auth scopes** | Not tested at handler level (framework enforces) — skip |
45
46
 
46
47
  ## Templates
@@ -134,6 +135,8 @@ describe('{{RESOURCE_EXPORT}}', () => {
134
135
  // expect(err.code).toBe(JsonRpcErrorCode.NotFound);
135
136
  // expect(err.data.reason).toBe('no_match');
136
137
 
138
+ // Include this block only when the resource definition exports a `list` function.
139
+ // Check the source — `list` is optional on resource definitions.
137
140
  it('lists available resources', async () => {
138
141
  const listing = await {{RESOURCE_EXPORT}}.list!();
139
142
  expect(listing.resources).toBeInstanceOf(Array);
@@ -156,14 +159,16 @@ describe('{{RESOURCE_EXPORT}}', () => {
156
159
 
157
160
  import { beforeEach, describe, expect, it } from 'vitest';
158
161
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
162
+ import { StorageService } from '@cyanheads/mcp-ts-core/storage';
159
163
  import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
160
164
 
161
- import { createInMemoryStorage } from '@cyanheads/mcp-ts-core/testing';
165
+ // Derive the minimal mock config from src/config/server-config.ts — read
166
+ // the server's Zod schema to see which fields init{{ServiceClass}}() needs.
167
+ const mockConfig = { /* fields from server config schema */ } as AppConfig;
162
168
 
163
169
  describe('{{ServiceClass}}', () => {
164
- beforeEach(() => {
165
- // Re-initialize with fresh config/storage for each test
166
- const mockStorage = createInMemoryStorage();
170
+ beforeEach(async () => {
171
+ const mockStorage = await StorageService.create({ type: 'in-memory' });
167
172
  init{{ServiceClass}}(mockConfig, mockStorage);
168
173
  });
169
174
 
@@ -206,8 +211,42 @@ it('respects cancellation', async () => {
206
211
  setTimeout(() => controller.abort(), 50);
207
212
  const result = await {{TOOL_EXPORT}}.handler(input, ctx);
208
213
 
209
- // Should have stopped early
210
- expect(result.finalCount).toBeGreaterThan(0);
214
+ // Should have returned a partial result rather than throwing on cancellation.
215
+ // Assert on a field from the tool's actual output schema.
216
+ expect(result).toBeDefined();
217
+ });
218
+ ```
219
+
220
+ ### Prompt test
221
+
222
+ ```typescript
223
+ /**
224
+ * @fileoverview Tests for {{PROMPT_NAME}} prompt.
225
+ * @module tests/prompts/{{PROMPT_NAME}}.prompt.test
226
+ */
227
+
228
+ import { describe, expect, it } from 'vitest';
229
+ import { {{PROMPT_EXPORT}} } from '@/mcp-server/prompts/definitions/{{prompt-name}}.prompt.js';
230
+
231
+ describe('{{PROMPT_EXPORT}}', () => {
232
+ it('generates valid messages for valid args', () => {
233
+ const args = {{PROMPT_EXPORT}}.args!.parse({
234
+ // valid args matching the Zod schema
235
+ });
236
+ const messages = {{PROMPT_EXPORT}}.generate(args);
237
+ expect(messages).toBeInstanceOf(Array);
238
+ expect(messages.length).toBeGreaterThan(0);
239
+ for (const msg of messages) {
240
+ expect(msg).toHaveProperty('role');
241
+ expect(msg).toHaveProperty('content');
242
+ }
243
+ });
244
+
245
+ // Include only when the prompt has no required args (args is optional or all fields optional).
246
+ it('generates messages with no args', () => {
247
+ const messages = {{PROMPT_EXPORT}}.generate({});
248
+ expect(messages.length).toBeGreaterThan(0);
249
+ });
211
250
  });
212
251
  ```
213
252
 
@@ -249,6 +288,8 @@ When scaffolding tests for an existing handler, use the Zod schemas to generate
249
288
  - [ ] `format` function tested if defined
250
289
  - [ ] `createMockContext` options match handler's ctx usage (`tenantId`, `progress`, `elicit`, `sample`)
251
290
  - [ ] Service re-initialized in `beforeEach` if handler depends on a service singleton
252
- - [ ] If wrapping external API: sparse-payload case tested (omitted upstream fields still validate; `format()` does not invent facts)
291
+ - [ ] If handler has optional fields: tested with empty-string inner values (form-client simulation)
292
+ - [ ] If wrapping external API: sparse-payload case tested — fixture omits at least one optional upstream field; output still validates and `format()` renders uncertainty honestly instead of inventing values
293
+ - [ ] If target is a prompt: `generate()` tested with valid args and (when applicable) no args
253
294
  - [ ] `bun run test` passes
254
295
  - [ ] `bun run devcheck` passes
@@ -11,18 +11,16 @@ metadata:
11
11
 
12
12
  ## Context
13
13
 
14
- Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix and is registered into `createApp()` in `src/index.ts`. Some larger repos later add `definitions/index.ts` barrels; match the pattern already used by the project you're editing.
15
-
16
- For the full `tool()` API, `Context` interface, and error codes, read `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md`.
14
+ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix. The standard registration pattern uses a `definitions/index.ts` barrel that collects all tools into an `allToolDefinitions` array for `createApp()`. Fresh scaffolds from `init` start with direct imports in `src/index.ts` the barrel is introduced as definitions grow. Match the pattern already used by the project you're editing.
17
15
 
18
16
  ## Steps
19
17
 
20
- 1. **Ask the user** for the tool's name, purpose, and input/output shape
18
+ 1. **Gather** the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent
21
19
  2. **Determine if long-running** — if the tool involves streaming, polling, or
22
20
  multi-step async work, it should use `task: true`
23
21
  3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
24
22
  4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
25
- 5. **Run `bun run devcheck`** to verify
23
+ 5. **Run `bun run devcheck`** to verify — if Biome reports formatting issues, run `bun run format` to auto-fix, then re-run devcheck
26
24
  6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start:http`)
27
25
 
28
26
  ## Naming
@@ -136,6 +134,7 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
136
134
  output: z.object({ /* ... */ }),
137
135
 
138
136
  async handler(input, ctx) {
137
+ // ctx.progress is guaranteed non-null when task: true — the ! assertion is safe here.
139
138
  await ctx.progress!.setTotal(totalSteps);
140
139
  for (const step of steps) {
141
140
  if (ctx.signal.aborted) break;
@@ -528,18 +527,26 @@ Large payloads burn the agent's context window. Default to curated summaries; of
528
527
  ## Checklist
529
528
 
530
529
  - [ ] File created at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
530
+ - [ ] Tool name passed to `tool()` uses snake_case
531
+ - [ ] `title` field set
532
+ - [ ] `annotations` set correctly — `readOnlyHint: false` for write tools, `destructiveHint: true` for delete/overwrite tools
531
533
  - [ ] All Zod schema fields have `.describe()` annotations
532
534
  - [ ] Numeric `output` fields carry units in the field name (`sizeInBytes`, `durationInMs`, `priceInCents`, `latencyInMs`) — `.describe()` may be summarized away or truncated, but the field name persists into the JSON the agent reads. Exempt: dimensionless counts (`totalCount`, `itemCount`), indices (`index`, `position`)
533
535
  - [ ] Schemas use only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`)
534
536
  - [ ] JSDoc `@fileoverview` and `@module` header present
535
537
  - [ ] Optional nested objects guarded for empty inner values from form-based clients (check `?.field` truthiness, not just object presence)
536
- - [ ] `handler(input, ctx)` is pure throws on failure, no try/catch
538
+ - [ ] No `console` callsuse `ctx.log` for handler logging
539
+ - [ ] `handler(input, ctx)` is pure — throws on failure, no try/catch (exception: batch tools with per-item isolation use try/catch inside the loop — that's intentional, don't remove it)
537
540
  - [ ] `format()` renders every field in the output schema — enforced at lint time via sentinel injection, startup fails with `format-parity` errors otherwise. Different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data. Primary fix: render the missing field in `format()` (use `z.discriminatedUnion` for list/detail variants). Escape hatch: if the output schema was over-typed for a genuinely dynamic upstream API, relax it (`z.object({}).passthrough()`) rather than maintaining aspirational typing
538
541
  - [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
539
542
  - [ ] `auth` scopes declared if the tool needs authorization
540
543
  - [ ] `errors: [...]` contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
541
544
  - [ ] Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
542
545
  - [ ] `task: true` added if the tool is long-running
546
+ - [ ] If `task: true`: handler checks `ctx.signal.aborted` in its loop for cancellation support
547
+ - [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for tabular working sets
548
+ - [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
543
549
  - [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
550
+ - [ ] Test file created via `add-test` skill, or handler tested directly with `createMockContext()`
544
551
  - [ ] `bun run devcheck` passes
545
552
  - [ ] Smoke-tested with `bun run rebuild && bun run start:stdio` (or `start:http`)
@@ -141,7 +141,8 @@ A `WARNING`-level log is emitted at startup whenever the flag is active so opera
141
141
  | `GET /healthz` | No |
142
142
  | `GET /mcp` | No |
143
143
  | `POST /mcp` | Yes (when auth enabled) |
144
- | `OPTIONS /mcp` | Yes (when auth enabled) |
144
+ | `DELETE /mcp` | Yes (when auth enabled) — session termination |
145
+ | `OPTIONS /mcp` | No (handled by CORS middleware before auth) |
145
146
 
146
147
  **CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access.
147
148
 
@@ -196,7 +197,7 @@ interface AuthContext {
196
197
  clientId: string; // Required — 'cid' or 'client_id' JWT claim
197
198
  scopes: string[]; // Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims
198
199
  sub: string; // Required — 'sub' claim; falls back to clientId when absent
199
- token: string; // Required — raw JWT or OAuth bearer token string
200
+ token?: string; // Optional — raw JWT or OAuth bearer token string (present when transport provides it)
200
201
  tenantId?: string; // Optional — 'tid' claim; present only for multi-tenant tokens
201
202
  }
202
203
  ```
@@ -13,7 +13,7 @@ metadata:
13
13
 
14
14
  `DataCanvas` is a primitive for **storage stashes, canvas computes**. The existing `IStorageProvider` is a key/value abstraction — it can stash blobs but exposes no analytical surface. `DataCanvas` is the analytical surface: register tabular data from upstream APIs, run SQL across multiple registered tables, and export results as CSV/Parquet/JSON.
15
15
 
16
- **Tier 3** — `@duckdb/node-api` is an optional peer dependency. Servers that don't enable canvas pay zero install cost. Lazy-loaded on first use.
16
+ **Tier 3** — `@duckdb/node-api` is an optional peer dependency (`bun add @duckdb/node-api`). Servers that don't enable canvas pay zero install cost. Lazy-loaded on first use.
17
17
 
18
18
  **Disabled by default.** Set `CANVAS_PROVIDER_TYPE=duckdb` to enable. Otherwise `core.canvas` is `undefined`.
19
19
 
@@ -27,7 +27,27 @@ metadata:
27
27
  import type { DataCanvas, CanvasInstance, ColumnSchema } from '@cyanheads/mcp-ts-core/canvas';
28
28
  ```
29
29
 
30
- The framework wires the optional service onto `CoreServices`:
30
+ The framework wires the optional service onto `CoreServices`, accessible in the `setup()` callback — **not on `Context`**. Handlers access canvas via a module-level accessor:
31
+
32
+ ```ts
33
+ // src/services/canvas-accessor.ts
34
+ import type { DataCanvas } from '@cyanheads/mcp-ts-core/canvas';
35
+
36
+ let _canvas: DataCanvas | undefined;
37
+ export const setCanvas = (c: DataCanvas | undefined) => { _canvas = c; };
38
+ export const getCanvas = () => _canvas;
39
+ ```
40
+
41
+ ```ts
42
+ // src/index.ts — wire in setup()
43
+ import { setCanvas } from './services/canvas-accessor.js';
44
+
45
+ await createApp({
46
+ setup(core) {
47
+ setCanvas(core.canvas);
48
+ },
49
+ });
50
+ ```
31
51
 
32
52
  ```ts
33
53
  interface CoreServices {
@@ -74,7 +94,11 @@ The sweeper runs as an `unref`'d `setInterval` — does not keep the event loop
74
94
  Resolves an existing canvas or creates a new one. Returns a {@link CanvasInstance} bound to `(canvasId, tenantId)`. Subsequent operations don't repeat them.
75
95
 
76
96
  ```ts
77
- const instance = await ctx.core.canvas!.acquire(input.canvas_id, ctx);
97
+ import { getCanvas } from '@/services/canvas-accessor.js';
98
+
99
+ const canvas = getCanvas();
100
+ if (!canvas) throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
101
+ const instance = await canvas.acquire(input.canvas_id, ctx);
78
102
  // instance.canvasId — surface to the agent
79
103
  // instance.isNew — true on first call
80
104
  // instance.expiresAt — ISO 8601 after sliding extension
@@ -116,7 +140,7 @@ const joined = await instance.query(`
116
140
  // joined.tableName === 'g_with_obs'; joined.rows.length === 10; joined.rowCount === <full count>
117
141
  ```
118
142
 
119
- `registerAs` rejects with `Conflict` if the target name already exists — drop it first.
143
+ `registerAs` rejects with `ValidationError` (`data.reason: 'register_as_clash'`) if the target name already exists — drop it first.
120
144
 
121
145
  **Read-only enforcement** (four layers):
122
146
  1. Text-level deny-list — pre-parse scan for file/HTTP-reading table functions (`read_csv*`, `read_json*`, `read_parquet*`, `read_text`, `read_blob`, `glob`, `iceberg_scan`, `delta_scan`, `postgres_scan`, `mysql_scan`, `sqlite_scan`, plus pre-staged spatial ones).
@@ -225,6 +249,7 @@ If your tool surfaces row data via `structuredContent`, the JSON-safe shape flow
225
249
 
226
250
  ```ts
227
251
  import { tool, z } from '@cyanheads/mcp-ts-core';
252
+ import { getCanvas } from '@/services/canvas-accessor.js';
228
253
 
229
254
  export const fetchAndStage = tool('fetch_and_stage_germplasm', {
230
255
  description: 'Fetch germplasm matching a query and stage it on a DataCanvas for follow-up SQL.',
@@ -245,7 +270,7 @@ export const fetchAndStage = tool('fetch_and_stage_germplasm', {
245
270
  expires_at: z.string().describe('ISO 8601 expiry after sliding 24h window'),
246
271
  }),
247
272
  async handler(input, ctx) {
248
- const canvas = ctx.core.canvas;
273
+ const canvas = getCanvas();
249
274
  if (!canvas) {
250
275
  throw new Error('DataCanvas is not enabled. Set CANVAS_PROVIDER_TYPE=duckdb.');
251
276
  }
@@ -334,7 +359,7 @@ When the preview budget is small (single-digit rows) and the sniff window matter
334
359
 
335
360
  ### Out of scope
336
361
 
337
- - **Provenance metadata** (source URI, original query). Caller stores externally — see #112 option 3.
362
+ - **Provenance metadata** (source URI, original query). Caller stores externally via `ctx.state` or tool output canvas tables carry data only, not lineage.
338
363
  - **Pagination-flavored builder.** A `paginate(fetchPage) → AsyncIterable<Row>` adapter is deferred until a second non-paginated consumer surfaces.
339
364
  - **Token-accurate budget.** `previewTokens` (tokenizer-driven) is a future option; characters cover the common case.
340
365
  - **`caps.maxBytes`.** Row caps cover the common case without re-doing serialization the canvas appender skips.
@@ -363,6 +388,18 @@ When the preview budget is small (single-digit rows) and the sniff window matter
363
388
 
364
389
  ---
365
390
 
391
+ ## Checklist
392
+
393
+ - [ ] `@duckdb/node-api` installed as a peer dependency (`bun add @duckdb/node-api`)
394
+ - [ ] `CANVAS_PROVIDER_TYPE=duckdb` set in `.env`
395
+ - [ ] Canvas accessor module created (`src/services/canvas-accessor.ts` or equivalent)
396
+ - [ ] Accessor wired in `setup()` callback via `setCanvas(core.canvas)`
397
+ - [ ] Handler guards for canvas availability (`if (!canvas) throw ...`)
398
+ - [ ] `canvas_id` accepted as optional input, returned in output
399
+ - [ ] SQL queries are read-only (enforced by the four-layer gate, but don't attempt writes)
400
+ - [ ] Testing: mock the module-level `getCanvas()` accessor with `vi.spyOn` or a test setup that calls `setCanvas(mockCanvas)`
401
+ - [ ] `bun run devcheck` passes
402
+
366
403
  ## Related skills
367
404
 
368
405
  - `add-tool` — scaffold a new MCP tool definition (use the canvas template above)
@@ -59,6 +59,10 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
59
59
  | `MCP_RESPONSE_VERBOSITY` | `mcpResponseVerbosity` | `standard` | `minimal` \| `standard` \| `full` |
60
60
  | `MCP_ALLOWED_ORIGINS` | `mcpAllowedOrigins` | — | Comma-separated list; omit to allow all |
61
61
  | `MCP_SERVER_RESOURCE_IDENTIFIER` | `mcpServerResourceIdentifier` | — | RFC 8707 resource indicator URL |
62
+ | `MCP_PUBLIC_URL` | `mcpPublicUrl` | — | Public-facing origin for reverse proxies (Cloudflare Tunnel, nginx, ALB) so emitted URLs carry the correct scheme |
63
+ | `MCP_HEARTBEAT_INTERVAL_MS` | `mcpHeartbeatIntervalMs` | `0` (disabled) | Heartbeat ping interval; 0 disables |
64
+ | `MCP_HEARTBEAT_MISS_THRESHOLD` | `mcpHeartbeatMissThreshold` | `3` | Missed heartbeats before session is considered stale |
65
+ | `MCP_GC_PRESSURE_INTERVAL_MS` | `mcpGcPressureIntervalMs` | `0` (disabled) | Bun-only opt-in forced GC loop for HTTP deployments with heap growth |
62
66
 
63
67
  ---
64
68
 
@@ -75,6 +79,8 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod from environment variable
75
79
  | `OAUTH_JWKS_COOLDOWN_MS` | `oauthJwksCooldownMs` | `300000` | 5 min; min time between JWKS refetches |
76
80
  | `OAUTH_JWKS_TIMEOUT_MS` | `oauthJwksTimeoutMs` | `5000` | JWKS fetch timeout (ms) |
77
81
  | `DEV_MCP_AUTH_BYPASS` | `devMcpAuthBypass` | `false` | Skip auth in development; blocked in `production` |
82
+ | `MCP_JWT_EXPECTED_ISSUER` | `mcpJwtExpectedIssuer` | — | Optional issuer validation for JWT mode |
83
+ | `MCP_JWT_EXPECTED_AUDIENCE` | `mcpJwtExpectedAudience` | — | Optional audience validation for JWT mode |
78
84
  | `DEV_MCP_CLIENT_ID` | `devMcpClientId` | — | Dev-only: override client ID |
79
85
  | `DEV_MCP_SCOPES` | `devMcpScopes` | — | Dev-only: comma-separated scope overrides |
80
86
 
@@ -42,9 +42,11 @@ interface Context {
42
42
  readonly elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
43
43
  readonly sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
44
44
 
45
- // Resource notifications — present when transport supports them
45
+ // Notifications — present when transport supports them
46
46
  readonly notifyResourceListChanged?: () => void;
47
47
  readonly notifyResourceUpdated?: (uri: string) => void;
48
+ readonly notifyPromptListChanged?: () => void;
49
+ readonly notifyToolListChanged?: () => void;
48
50
 
49
51
  // Cancellation
50
52
  readonly signal: AbortSignal;
@@ -415,8 +417,10 @@ Present only when the definition declares an `errors[]` contract. Builds an `Mcp
415
417
  export const fetchItems = tool('fetch_items', {
416
418
  description: 'Fetch items by ID.',
417
419
  errors: [
418
- { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched' },
419
- { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity', retryable: true },
420
+ { reason: 'no_match', code: JsonRpcErrorCode.NotFound, when: 'No items matched',
421
+ recovery: 'Broaden the query or check the spelling and try again.' },
422
+ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity', retryable: true,
423
+ recovery: 'Wait a few seconds before retrying or reduce batch size.' },
420
424
  ],
421
425
  input: z.object({ ids: z.array(z.string()).describe('Item IDs') }),
422
426
  output: z.object({ items: z.array(ItemSchema).describe('Resolved items') }),
@@ -534,6 +538,8 @@ The `≥5 words` lint rule on contract `recovery` (validated at lint time) makes
534
538
  | `ctx.sample` | `function \| undefined` | Client supports sampling |
535
539
  | `ctx.notifyResourceListChanged` | `function \| undefined` | Transport supports resource notifications |
536
540
  | `ctx.notifyResourceUpdated` | `function \| undefined` | Transport supports resource notifications |
541
+ | `ctx.notifyPromptListChanged` | `function \| undefined` | Transport supports prompt notifications |
542
+ | `ctx.notifyToolListChanged` | `function \| undefined` | Transport supports tool notifications |
537
543
  | `ctx.progress` | `ContextProgress \| undefined` | Tool defined with `task: true` |
538
544
  | `ctx.uri` | `URL \| undefined` | Resource handlers only |
539
545
  | `ctx.fail` | `(reason, msg?, data?, opts?) => McpError` | Definition declares `errors[]` contract |
@@ -11,7 +11,7 @@ metadata:
11
11
 
12
12
  ## Overview
13
13
 
14
- Error handling in `@cyanheads/mcp-ts-core` follows a strict layered pattern: tool and resource handlers throw `McpError` freely (no try/catch), the handler factory catches and normalizes all errors, and services use `ErrorHandler.tryCatch` for graceful recovery.
14
+ Error handling in `@cyanheads/mcp-ts-core` follows a strict layered pattern: tool and resource handlers throw `McpError` freely (no try/catch), the handler factory catches and normalizes all errors, and services use `ErrorHandler.tryCatch` for structured logging and wrapping.
15
15
 
16
16
  **Imports:**
17
17
 
@@ -64,7 +64,7 @@ export const fetchTool = tool('fetch_articles', {
64
64
  |:--------|:---------|
65
65
  | Compile time | `ctx.fail('typo')` is a TS error. Auto-completes declared reasons. |
66
66
  | Runtime | `ctx.fail(reason, msg?, data?, options?)` builds an `McpError(contract.code, msg, { ...data, reason }, options)` — `data.reason` is auto-populated from the contract and cannot be overridden by caller-supplied data (spread first, then `reason` written last), so observers see a stable identifier. `options` accepts `{ cause }` for ES2022 error chaining. |
67
- | Lint (startup) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. |
67
+ | Lint (devcheck) | Each `code` validated against `JsonRpcErrorCode`. Reasons validated as snake_case + unique within contract. `recovery` validated as non-empty and ≥ 5 words. Build-time only — not invoked at server startup. |
68
68
  | Lint (conformance) | If the handler `throw new McpError(JsonRpcErrorCode.X)` outside `ctx.fail`, conformance check warns when X isn't declared. |
69
69
 
70
70
  > **`recovery` is opt-in resolution, not auto-population.** The contract `recovery` is required metadata documenting the agent's next move when this failure mode fires (a forcing function for thoughtful guidance — placeholders like "Try again." get flagged by the linter). It does **not** automatically appear in runtime `data.recovery.hint` — the framework never injects it without an explicit signal at the throw site. Authors opt in by spreading `ctx.recoveryFor('reason')` into the `data` argument, the same way `ctx.fail('reason')` opts into resolving the contract `code`. What the author types at the throw site is what flows to the wire, with no hidden transformation; the resolver is just a typed lookup keyed by the same `reason` the author already typed.
@@ -126,7 +126,7 @@ throw ctx.fail('no_match', `No item ${id}`, {
126
126
 
127
127
  ### Carrying contract `reason` from services
128
128
 
129
- Services don't have `ctx`, so they can't call `ctx.fail`. To make a service-thrown failure carry the contract's `reason` on the wire, **pass `data: { reason: 'X' }` to the factory**. The framework's auto-classifier preserves `data` unchanged, so clients see the same `error.data.reason` they'd see from `ctx.fail`:
129
+ Services don't receive `ctx` automatically (unlike handlers), so they can't call `ctx.fail` directly — though `ctx` can be passed as a parameter when needed. To make a service-thrown failure carry the contract's `reason` on the wire, **pass `data: { reason: 'X' }` to the factory**. The framework's auto-classifier preserves `data` unchanged, so clients see the same `error.data.reason` they'd see from `ctx.fail`:
130
130
 
131
131
  ```ts
132
132
  // my-service.ts
@@ -271,7 +271,7 @@ Use factories or `McpError` directly when the code must be exact — auto-classi
271
271
  The framework applies these steps in order — first match wins:
272
272
 
273
273
  1. **`McpError` instance** — `error.code` is preserved as-is; no classification needed.
274
- 2. **JS constructor name** — matched against a fixed table (e.g. `TypeError` → `ValidationError`).
274
+ 2. **JS constructor name** — matched against a fixed table (e.g. `ZodError` → `ValidationError`, `SyntaxError` → `ValidationError`). Note: `TypeError` is intentionally excluded — runtime TypeErrors are programmer errors, not validation failures.
275
275
  3. **Provider-specific patterns** — HTTP status codes, AWS exception names, Supabase, OpenRouter. Checked before common patterns because they are more specific (e.g. `status code 429` beats the generic `rate limit` pattern).
276
276
  4. **Common message/name patterns** — broad keyword patterns covering auth, not-found, validation, etc. First match wins; order matters.
277
277
  5. **`AbortError` name** — `error.name === 'AbortError'` → `Timeout`.
@@ -344,7 +344,7 @@ Checked before common patterns. Cover: AWS exception names, HTTP status codes, D
344
344
  | Tool/resource handlers | Throw `McpError` — no try/catch |
345
345
  | Handler factory (tools) | Catches all errors, normalizes to `McpError`, sets `isError: true`, mirrors error across both client surfaces (see [Error-path parity](#error-path-parity)) |
346
346
  | Handler factory (resources) | Catches and re-throws to the SDK, which routes through the JSON-RPC error envelope |
347
- | Services/setup code | `ErrorHandler.tryCatch` for graceful recovery |
347
+ | Services/setup code | `ErrorHandler.tryCatch` for structured logging and wrapping (always rethrows — never swallows) |
348
348
 
349
349
  ### Error-path parity
350
350
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: api-linter
3
3
  description: >
4
- MCP definition linter rules reference. Use when `bun run lint:mcp`, `bun run devcheck`, or `createApp()` startup reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
4
+ MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
5
5
  metadata:
6
6
  author: cyanheads
7
7
  version: "1.3"
@@ -11,19 +11,18 @@ metadata:
11
11
 
12
12
  ## Overview
13
13
 
14
- The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. It runs in three places:
14
+ The linter validates tool, resource, and prompt definitions against the MCP spec and framework conventions. **It is build-time only — not invoked at server startup.** It runs in two places:
15
15
 
16
16
  | Entry point | When | On failure |
17
17
  |:------------|:-----|:-----------|
18
- | `createApp()` / `createWorkerHandler()` | Every startup | Throws `ConfigurationError`; process exits with a formatted banner. Warnings are logged and startup continues. |
19
18
  | `bun run lint:mcp` | Manual or CI | Prints errors + warnings, exits non-zero on errors. |
20
19
  | `bun run devcheck` | Pre-commit workflow | Wraps `lint:mcp` alongside typecheck, format, `bun audit`, `bun outdated`. |
21
20
 
22
- All three surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`). Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
21
+ Both surface the same `LintReport` from `validateDefinitions()` (exported from `@cyanheads/mcp-ts-core/linter`). Each diagnostic has a stable `rule` ID — that's the anchor you land on via the `See: skills/api-linter/SKILL.md#<rule>` breadcrumb appended to every message.
23
22
 
24
23
  **Severity:**
25
- - **error** — MUST-level spec violation; blocks startup.
26
- - **warning** — SHOULD-level or quality issue; logged but startup continues.
24
+ - **error** — MUST-level spec violation; blocks `devcheck`.
25
+ - **warning** — SHOULD-level or quality issue; logged but `devcheck` continues.
27
26
 
28
27
  **Imports (if you need to run the linter programmatically):**
29
28
 
@@ -52,7 +51,7 @@ Grouped by family. Jump to any rule ID via its anchor.
52
51
  | Landing | `landing-*` (23 rules — shape, tagline, logo, links, repo, envExample, connectSnippets, theme) | [Landing config rules](#landing-config-rules) |
53
52
  | Prompts | `generate-required` | [Prompt rules](#prompt-rules) |
54
53
  | Handler body | `prefer-mcp-error-in-handler`, `prefer-error-factory`, `preserve-cause-on-rethrow`, `no-stringify-upstream-error` | [Handler body rules](#handler-body-rules) |
55
- | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type` | [Error contract rules](#error-contract-rules) |
54
+ | Error contract (structural) | `error-contract-type`, `error-contract-empty`, `error-contract-entry-type`, `error-contract-code-type`, `error-contract-code-unknown`, `error-contract-code-unknown-error`, `error-contract-reason-required`, `error-contract-reason-format`, `error-contract-reason-unique`, `error-contract-when-required`, `error-contract-retryable-type`, `error-contract-recovery-required`, `error-contract-recovery-empty`, `error-contract-recovery-min-words` | [Error contract rules](#error-contract-rules) |
56
55
  | Error contract (conformance) | `error-contract-conformance`, `error-contract-prefer-fail` | [Error contract rules](#error-contract-rules) |
57
56
  | server.json | ~40 rules prefixed `server-json-*` | [server.json rules](#server-json-rules) |
58
57
 
@@ -515,7 +514,9 @@ Heuristic source-text checks that scan `handler.toString()` for common error-han
515
514
 
516
515
  Fires when a handler contains `throw new Error(...)`. Plain `Error` doesn't carry a JSON-RPC code — the framework's auto-classifier degrades to `InternalError`, hiding the actual failure mode.
517
516
 
518
- **Fix:** use `McpError` or a factory:
517
+ Plain `Error` is acceptable for "don't care" cases where the specific code doesn't matter (per CLAUDE.md: "plain `Error` for don't-care cases"). This rule targets domain-specific failures that deserve a concrete code — upgrade those to factories or `ctx.fail`, and accept the warning for the rest.
518
+
519
+ **Fix:** use `McpError` or a factory for domain-specific failures:
519
520
 
520
521
  ```ts
521
522
  // instead of:
@@ -596,7 +597,7 @@ Fires when `errors: []` is declared. An empty contract is a no-op — nothing to
596
597
 
597
598
  **Severity:** error
598
599
 
599
- Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when }` (and optionally `retryable`).
600
+ Fires when an entry in `errors[]` isn't an object. Each entry must be `{ code, reason, when, recovery }` (and optionally `retryable`).
600
601
 
601
602
  ### error-contract-code-type
602
603
 
@@ -654,6 +655,28 @@ Fires when an entry's `when` field is missing or empty. `when` is the human-read
654
655
 
655
656
  Fires when an entry's optional `retryable` field is present but isn't a boolean. Only `true` or `false` is meaningful — drop the field if you can't commit to either.
656
657
 
658
+ ### error-contract-recovery-required
659
+
660
+ **Severity:** error
661
+
662
+ Fires when an entry's `recovery` field is missing or not a string. `recovery` is the agent's next-move guidance when this failure fires — it flows to the wire via `ctx.recoveryFor`.
663
+
664
+ ### error-contract-recovery-empty
665
+
666
+ **Severity:** error
667
+
668
+ Fires when `recovery` is an empty string. A blank recovery is worse than none — it suggests the field was considered and deliberately left empty.
669
+
670
+ **Fix:** write a concrete recovery hint (≥5 words).
671
+
672
+ ### error-contract-recovery-min-words
673
+
674
+ **Severity:** warning
675
+
676
+ Fires when `recovery` has fewer than 5 words. Short recoveries like "Try again." are too vague to guide an agent's next action.
677
+
678
+ **Fix:** expand with specifics — what to try, what parameter to change, which tool to call instead.
679
+
657
680
  ### error-contract-conformance
658
681
 
659
682
  **Severity:** warning
@@ -11,7 +11,7 @@ metadata:
11
11
 
12
12
  ## Overview
13
13
 
14
- Service interfaces are deferred from core's public exports — they remain in downstream servers until shared by 2+ servers. These are documented here for core contributors and servers that use the built-in providers.
14
+ Service providers are exported from `@cyanheads/mcp-ts-core/services`. These are documented here for servers that use the built-in providers.
15
15
 
16
16
  All services follow the **init/accessor pattern**: initialized in `setup()`, accessed at request time via lazy accessor. See the `add-service` skill for the full pattern.
17
17
 
@@ -111,7 +111,7 @@ const path = await graphService.shortestPath('user:alice', 'user:charlie', conte
111
111
  algorithm: 'bfs',
112
112
  maxLength: 4,
113
113
  });
114
- if (path) context.log.info(`${path.vertices.length} hops`);
114
+ // path.vertices.length gives the hop count
115
115
 
116
116
  // Check reachability
117
117
  const connected = await graphService.pathExists('user:alice', 'user:charlie', context, 3);
@@ -24,7 +24,7 @@ The provider interface — implemented by ElevenLabs (TTS) and Whisper (STT):
24
24
  | `.getSTTProvider()` | `ISpeechProvider` | Throws `McpError(InvalidRequest)` if no STT provider configured |
25
25
  | `.hasTTS()` | `boolean` | Check if TTS is available |
26
26
  | `.hasSTT()` | `boolean` | Check if STT is available |
27
- | `.healthCheck()` | `Promise<{ tts: boolean; stt: boolean }>` | Checks both providers in parallel |
27
+ | `.healthCheck()` | `Promise<{ tts: boolean; stt: boolean }>` | Checks both providers sequentially |
28
28
 
29
29
  ## Providers
30
30
 
@@ -11,7 +11,7 @@ metadata:
11
11
 
12
12
  ## Overview
13
13
 
14
- The framework auto-instruments every tool, resource, prompt, storage, LLM, speech, and graph call — each gets its own span and the standard counters/histograms. HTTP server requests pick up spans from `HttpInstrumentation` (or `@hono/otel` on the HTTP transport). Auth checks, session lifecycle, and task lifecycle are tracked as **metrics only** — auth decorates the active HTTP span with attributes, sessions and tasks emit counters.
14
+ The framework auto-instruments every tool, resource, prompt, storage, LLM, speech, and graph call — each gets its own span and the standard counters/histograms. HTTP server requests pick up spans from `HttpInstrumentation` (all Node.js HTTP traffic, skips `/healthz`) plus `httpInstrumentationMiddleware` from `@hono/otel` on the MCP HTTP endpoint when installed (optional Tier 3 peer — `bun add @hono/otel`). On Bun, `HttpInstrumentation` silently no-ops and `@hono/otel` is the only HTTP coverage. Auth checks, session lifecycle, and task lifecycle are tracked as **metrics only** — auth decorates the active HTTP span with attributes, sessions and tasks emit counters.
15
15
 
16
16
  `requestId`, `traceId`, and `tenantId` correlate automatically across spans, metrics, and logs. Pino logs get `trace_id`/`span_id` injected when a span is active.
17
17
 
@@ -76,7 +76,7 @@ Trace context propagates across boundaries via W3C `traceparent` headers. See `a
76
76
 
77
77
  ## Metrics
78
78
 
79
- All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` where standard semconv applies). Lazy-initialized on first emission; the universal ones are eagerly created at startup so series exist from the first export cycle.
79
+ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` where standard semconv applies). Lazy-initialized on first emission; tool, resource, prompt, `http.client.request.duration`, heartbeat, session, auth, rate-limit, and error metrics are eagerly created at startup so series exist from the first export cycle. LLM, speech, graph, and storage instruments are lazy-initialized on first use.
80
80
 
81
81
  ### Tools, resources, prompts
82
82
 
@@ -86,17 +86,17 @@ All custom metrics are namespaced `mcp.*` (or `process.*` / `http.client.*` wher
86
86
  | `mcp.tool.duration` | histogram | `ms` | `mcp.tool.name`, `mcp.tool.success` |
87
87
  | `mcp.tool.errors` | counter | `{errors}` | `mcp.tool.name`, `mcp.tool.error_category` (`upstream`/`server`/`client`) |
88
88
  | `mcp.tool.input_bytes` | histogram | `bytes` | `mcp.tool.name` |
89
- | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` |
89
+ | `mcp.tool.output_bytes` | histogram | `bytes` | `mcp.tool.name` (success only) |
90
90
  | `mcp.tool.param.usage` | counter | `{uses}` | `mcp.tool.name`, `mcp.tool.param` (top-level keys supplied by caller) |
91
91
  | `mcp.resource.reads` | counter | `{reads}` | `mcp.resource.name`, `mcp.resource.success` |
92
92
  | `mcp.resource.duration` | histogram | `ms` | `mcp.resource.name`, `mcp.resource.success` |
93
93
  | `mcp.resource.errors` | counter | `{errors}` | `mcp.resource.name` |
94
- | `mcp.resource.output_bytes` | histogram | `bytes` | `mcp.resource.name` |
94
+ | `mcp.resource.output_bytes` | histogram | `bytes` | `mcp.resource.name` (success only) |
95
95
  | `mcp.prompt.generations` | counter | `{generations}` | `mcp.prompt.name`, `mcp.prompt.success` |
96
96
  | `mcp.prompt.duration` | histogram | `ms` | `mcp.prompt.name`, `mcp.prompt.success` |
97
97
  | `mcp.prompt.errors` | counter | `{errors}` | `mcp.prompt.name`, `mcp.prompt.error_category` |
98
98
  | `mcp.prompt.input_bytes` | histogram | `bytes` | `mcp.prompt.name` |
99
- | `mcp.prompt.output_bytes` | histogram | `bytes` | `mcp.prompt.name` |
99
+ | `mcp.prompt.output_bytes` | histogram | `bytes` | `mcp.prompt.name` (success only) |
100
100
  | `mcp.prompt.message_count` | histogram | `{messages}` | `mcp.prompt.name` |
101
101
  | `mcp.requests.active` | up/down counter | `{requests}` | — (in-flight handler executions, all three types) |
102
102
 
@@ -13,6 +13,8 @@ metadata:
13
13
 
14
14
  Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
15
15
 
16
+ **Additional exports from `/testing`:** `createMockLogger()` returns a standalone `MockContextLogger` for unit-testing code that accepts a `ContextLogger` directly (services, utilities). `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider` for testing services that take a `StorageService` dependency.
17
+
16
18
  **Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
17
19
 
18
20
  ---
@@ -43,9 +45,12 @@ interface MockContextOptions {
43
45
  auth?: AuthContext;
44
46
  elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
45
47
  errors?: readonly ErrorContract[];
48
+ notifyPromptListChanged?: () => void;
46
49
  notifyResourceListChanged?: () => void;
47
50
  notifyResourceUpdated?: (uri: string) => void;
51
+ notifyToolListChanged?: () => void;
48
52
  progress?: boolean;
53
+ sessionId?: string;
49
54
  requestId?: string;
50
55
  sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
51
56
  signal?: AbortSignal;
@@ -60,8 +65,11 @@ interface MockContextOptions {
60
65
  | `auth` | Sets `ctx.auth` for scope-checking tests |
61
66
  | `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
62
67
  | `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
68
+ | `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
63
69
  | `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
64
70
  | `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
71
+ | `notifyToolListChanged` | Assigns `ctx.notifyToolListChanged` for tool-list change notification tests |
72
+ | `sessionId` | Sets `ctx.sessionId` for handlers that branch on session ID |
65
73
  | `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
66
74
  | `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
67
75
  | `sample` | Assigns a function to `ctx.sample` for testing sampling calls |
@@ -93,7 +101,7 @@ expect(progress._messages).toContain('step message');
93
101
 
94
102
  ### Mock logger
95
103
 
96
- `ctx.log` captures all log calls for inspection. The mock returns the typed `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` import that instead of hand-casting:
104
+ `ctx.log` captures all log calls for inspection. Import `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` and cast `ctx.log` to access the `.calls` array (the cast is necessary because `createMockContext` returns `Context`, which types `log` as `ContextLogger`):
97
105
 
98
106
  ```ts
99
107
  import { createMockContext, type MockContextLogger } from '@cyanheads/mcp-ts-core/testing';
@@ -29,7 +29,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
29
29
 
30
30
  | Export | API | Notes |
31
31
  |:-------|:----|:------|
32
- | `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node; hostname-only on Workers. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. |
32
+ | `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. |
33
33
  | `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
34
34
  | `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. |
35
35
  | `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. |