@cyanheads/mcp-ts-core 0.11.2 → 0.11.4

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 (163) hide show
  1. package/AGENTS.md +14 -10
  2. package/CLAUDE.md +14 -10
  3. package/README.md +3 -3
  4. package/changelog/0.11.x/0.11.3.md +49 -0
  5. package/changelog/0.11.x/0.11.4.md +67 -0
  6. package/dist/config/index.d.ts +0 -10
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +1 -25
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/config/logLevelAlias.d.ts +15 -0
  11. package/dist/config/logLevelAlias.d.ts.map +1 -0
  12. package/dist/config/logLevelAlias.js +30 -0
  13. package/dist/config/logLevelAlias.js.map +1 -0
  14. package/dist/core/context.d.ts +10 -0
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +10 -1
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/worker.js +1 -1
  19. package/dist/core/worker.js.map +1 -1
  20. package/dist/linter/rules/enrichment-rules.d.ts +3 -2
  21. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  22. package/dist/linter/rules/enrichment-rules.js +35 -7
  23. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  24. package/dist/linter/rules/format-parity-rules.d.ts +8 -4
  25. package/dist/linter/rules/format-parity-rules.d.ts.map +1 -1
  26. package/dist/linter/rules/format-parity-rules.js +72 -20
  27. package/dist/linter/rules/format-parity-rules.js.map +1 -1
  28. package/dist/linter/rules/index.d.ts +1 -1
  29. package/dist/linter/rules/index.d.ts.map +1 -1
  30. package/dist/linter/rules/index.js +1 -1
  31. package/dist/linter/rules/index.js.map +1 -1
  32. package/dist/linter/rules/prompt-rules.d.ts.map +1 -1
  33. package/dist/linter/rules/prompt-rules.js +6 -3
  34. package/dist/linter/rules/prompt-rules.js.map +1 -1
  35. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  36. package/dist/linter/rules/resource-rules.js +11 -5
  37. package/dist/linter/rules/resource-rules.js.map +1 -1
  38. package/dist/linter/rules/schema-rules.d.ts +21 -2
  39. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  40. package/dist/linter/rules/schema-rules.js +110 -2
  41. package/dist/linter/rules/schema-rules.js.map +1 -1
  42. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  43. package/dist/linter/rules/tool-rules.js +11 -5
  44. package/dist/linter/rules/tool-rules.js.map +1 -1
  45. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +44 -10
  47. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  48. package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts +10 -10
  49. package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
  50. package/dist/services/mirror/sqlite/sqliteMirrorStore.js +29 -24
  51. package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
  52. package/dist/storage/core/storageValidation.d.ts +4 -4
  53. package/dist/storage/core/storageValidation.d.ts.map +1 -1
  54. package/dist/storage/core/storageValidation.js +4 -20
  55. package/dist/storage/core/storageValidation.js.map +1 -1
  56. package/dist/testing/fuzz.d.ts.map +1 -1
  57. package/dist/testing/fuzz.js +57 -2
  58. package/dist/testing/fuzz.js.map +1 -1
  59. package/dist/testing/index.d.ts +45 -13
  60. package/dist/testing/index.d.ts.map +1 -1
  61. package/dist/testing/index.js +29 -76
  62. package/dist/testing/index.js.map +1 -1
  63. package/dist/testing/vitest.d.ts +1 -1
  64. package/dist/utils/formatting/diffFormatter.d.ts +5 -5
  65. package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
  66. package/dist/utils/formatting/diffFormatter.js +1 -1
  67. package/dist/utils/formatting/diffFormatter.js.map +1 -1
  68. package/dist/utils/formatting/tableFormatter.d.ts +3 -3
  69. package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
  70. package/dist/utils/formatting/tableFormatter.js +1 -1
  71. package/dist/utils/formatting/tableFormatter.js.map +1 -1
  72. package/dist/utils/formatting/treeFormatter.d.ts +3 -3
  73. package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
  74. package/dist/utils/formatting/treeFormatter.js +1 -1
  75. package/dist/utils/formatting/treeFormatter.js.map +1 -1
  76. package/dist/utils/metrics/tokenCounter.d.ts +3 -3
  77. package/dist/utils/metrics/tokenCounter.d.ts.map +1 -1
  78. package/dist/utils/metrics/tokenCounter.js.map +1 -1
  79. package/dist/utils/network/fetchWithTimeout.d.ts +43 -15
  80. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  81. package/dist/utils/network/fetchWithTimeout.js +163 -44
  82. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  83. package/dist/utils/network/httpError.d.ts.map +1 -1
  84. package/dist/utils/network/httpError.js +1 -2
  85. package/dist/utils/network/httpError.js.map +1 -1
  86. package/dist/utils/network/responseBody.d.ts +27 -7
  87. package/dist/utils/network/responseBody.d.ts.map +1 -1
  88. package/dist/utils/network/responseBody.js +54 -13
  89. package/dist/utils/network/responseBody.js.map +1 -1
  90. package/dist/utils/network/retry.d.ts +2 -2
  91. package/dist/utils/network/retry.d.ts.map +1 -1
  92. package/dist/utils/overflow/outlineOnOverflow.d.ts +4 -3
  93. package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
  94. package/dist/utils/overflow/outlineOnOverflow.js +27 -8
  95. package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
  96. package/dist/utils/pagination/pagination.d.ts +3 -3
  97. package/dist/utils/pagination/pagination.d.ts.map +1 -1
  98. package/dist/utils/pagination/pagination.js.map +1 -1
  99. package/dist/utils/parsing/csvParser.d.ts +2 -2
  100. package/dist/utils/parsing/csvParser.d.ts.map +1 -1
  101. package/dist/utils/parsing/csvParser.js +1 -1
  102. package/dist/utils/parsing/csvParser.js.map +1 -1
  103. package/dist/utils/parsing/dateParser.d.ts +11 -7
  104. package/dist/utils/parsing/dateParser.d.ts.map +1 -1
  105. package/dist/utils/parsing/dateParser.js +8 -4
  106. package/dist/utils/parsing/dateParser.js.map +1 -1
  107. package/dist/utils/parsing/frontmatterParser.d.ts +5 -4
  108. package/dist/utils/parsing/frontmatterParser.d.ts.map +1 -1
  109. package/dist/utils/parsing/frontmatterParser.js +5 -4
  110. package/dist/utils/parsing/frontmatterParser.js.map +1 -1
  111. package/dist/utils/parsing/htmlExtractor.d.ts +5 -4
  112. package/dist/utils/parsing/htmlExtractor.d.ts.map +1 -1
  113. package/dist/utils/parsing/htmlExtractor.js +4 -3
  114. package/dist/utils/parsing/htmlExtractor.js.map +1 -1
  115. package/dist/utils/parsing/inputBudget.d.ts +18 -11
  116. package/dist/utils/parsing/inputBudget.d.ts.map +1 -1
  117. package/dist/utils/parsing/inputBudget.js +20 -17
  118. package/dist/utils/parsing/inputBudget.js.map +1 -1
  119. package/dist/utils/parsing/jsonParser.d.ts +5 -4
  120. package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
  121. package/dist/utils/parsing/jsonParser.js +5 -4
  122. package/dist/utils/parsing/jsonParser.js.map +1 -1
  123. package/dist/utils/parsing/pdfParser.d.ts +38 -24
  124. package/dist/utils/parsing/pdfParser.d.ts.map +1 -1
  125. package/dist/utils/parsing/pdfParser.js +42 -31
  126. package/dist/utils/parsing/pdfParser.js.map +1 -1
  127. package/dist/utils/parsing/xmlParser.d.ts +3 -3
  128. package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
  129. package/dist/utils/parsing/xmlParser.js +3 -6
  130. package/dist/utils/parsing/xmlParser.js.map +1 -1
  131. package/dist/utils/parsing/yamlParser.d.ts +3 -3
  132. package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
  133. package/dist/utils/parsing/yamlParser.js +3 -3
  134. package/dist/utils/parsing/yamlParser.js.map +1 -1
  135. package/dist/utils/security/rateLimiter.d.ts +2 -2
  136. package/dist/utils/security/rateLimiter.d.ts.map +1 -1
  137. package/dist/utils/security/rateLimiter.js +1 -1
  138. package/dist/utils/security/rateLimiter.js.map +1 -1
  139. package/dist/utils/telemetry/trace.d.ts +3 -3
  140. package/dist/utils/telemetry/trace.d.ts.map +1 -1
  141. package/dist/utils/telemetry/trace.js.map +1 -1
  142. package/package.json +1 -1
  143. package/scripts/tree.ts +9 -3
  144. package/skills/add-test/SKILL.md +2 -2
  145. package/skills/api-auth/SKILL.md +5 -5
  146. package/skills/api-context/SKILL.md +14 -12
  147. package/skills/api-linter/SKILL.md +59 -5
  148. package/skills/api-testing/SKILL.md +31 -10
  149. package/skills/api-utils/SKILL.md +4 -2
  150. package/skills/api-utils/references/parsing.md +3 -2
  151. package/skills/setup/SKILL.md +6 -3
  152. package/skills/techniques/SKILL.md +1 -1
  153. package/skills/techniques/references/outline-on-overflow.md +3 -1
  154. package/templates/AGENTS.md +1 -1
  155. package/templates/CLAUDE.md +1 -1
  156. package/templates/Dockerfile +7 -2
  157. package/templates/_tsconfig.build.json +5 -0
  158. package/templates/_tsconfig.json +4 -2
  159. package/templates/package.json +1 -0
  160. package/templates/tests/prompts/echo.prompt.test.ts +4 -3
  161. package/templates/tests/resources/echo.resource.test.ts +11 -3
  162. package/templates/tests/smoke/definitions.smoke.test.ts +8 -4
  163. package/templates/tests/tools/echo.tool.test.ts +23 -4
package/scripts/tree.ts CHANGED
@@ -131,9 +131,15 @@ async function loadIgnoreHandler(
131
131
  return ig;
132
132
  }
133
133
 
134
- function isIgnored(entryPath: string, root: string, ig: Ignore): boolean {
134
+ /**
135
+ * A directory-only pattern (`/data/`, `.claude/`) matches only when the tested
136
+ * path carries the trailing slash too — `ignore` has no other way to tell a
137
+ * directory from a file of the same name. The caller holds the `Dirent`, so the
138
+ * flag is free.
139
+ */
140
+ function isIgnored(entryPath: string, root: string, ig: Ignore, isDirectory: boolean): boolean {
135
141
  const rel = relative(root, entryPath).split(sep).join(posix.sep);
136
- return ig.ignores(rel);
142
+ return ig.ignores(isDirectory ? `${rel}/` : rel);
137
143
  }
138
144
 
139
145
  /**
@@ -182,7 +188,7 @@ async function generateTree(
182
188
  }
183
189
 
184
190
  const filteredEntries = entries
185
- .filter((entry) => !isIgnored(join(resolvedDir, entry.name), root, ig))
191
+ .filter((entry) => !isIgnored(join(resolvedDir, entry.name), root, ig, entry.isDirectory()))
186
192
  .sort((a, b) => {
187
193
  if (a.isDirectory() && !b.isDirectory()) return -1;
188
194
  if (!a.isDirectory() && b.isDirectory()) return 1;
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a test file for an existing tool, resource, or service. Use when the user asks to add tests, improve coverage, or when a definition exists without a matching test file.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.4"
7
+ version: "1.5"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -35,7 +35,7 @@ Read the handler and identify:
35
35
  | **Happy path** | Valid input → expected output. Include at least one. |
36
36
  | **Input variations** | Optional fields omitted, defaults applied, boundary values |
37
37
  | **Error paths** | Invalid state, missing resources, service failures → correct error thrown |
38
- | **`ctx.state` usage** | Use `createMockContext({ tenantId: 'test' })` to enable storage |
38
+ | **`ctx.state` usage** | Available on any mock context (tenant `'default'` unless `{ tenantId }` says otherwise). It runs the production storage path, so use storage-legal keys (`cache/v1/abc`, never `cache:v1:abc`) and assert TTL expiry with fake timers. |
39
39
  | **`ctx.elicit`** | Mock with `vi.fn()`, also test the absent case (undefined) |
40
40
  | **`ctx.progress`** | Use `createMockContext({ progress: true })` for task tools |
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`. |
@@ -4,7 +4,7 @@ description: >
4
4
  Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.2"
7
+ version: "1.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -175,11 +175,11 @@ A `WARNING`-level log is emitted at startup whenever the flag is active so opera
175
175
  ```ts
176
176
  handler: async (input, ctx) => {
177
177
  // Automatically scoped to ctx.tenantId — no manual prefixing
178
- await ctx.state.set('item:123', { name: 'Widget', count: 42 });
179
- const item = await ctx.state.get<Item>('item:123');
180
- await ctx.state.delete('item:123');
178
+ await ctx.state.set('item/123', { name: 'Widget', count: 42 });
179
+ const item = await ctx.state.get<Item>('item/123');
180
+ await ctx.state.delete('item/123');
181
181
 
182
- const page = await ctx.state.list('item:', { cursor, limit: 20 });
182
+ const page = await ctx.state.list('item/', { cursor, limit: 20 });
183
183
  // page: { items: Array<{ key, value }>, cursor?: string }
184
184
  },
185
185
  ```
@@ -4,7 +4,7 @@ description: >
4
4
  Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.progress`, `ctx.enrich`, `ctx.content`), and when to use each.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.9"
7
+ version: "1.11"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -155,23 +155,23 @@ interface ContextState {
155
155
 
156
156
  ```ts
157
157
  // Store — accepts any serializable value, no manual JSON.stringify needed
158
- await ctx.state.set('item:123', { name: 'Widget', count: 42 });
159
- await ctx.state.set('session:xyz', token, { ttl: 3600 }); // TTL in seconds
158
+ await ctx.state.set('item/123', { name: 'Widget', count: 42 });
159
+ await ctx.state.set('session/xyz', token, { ttl: 3600 }); // TTL in seconds
160
160
 
161
161
  // Retrieve — generic type assertion or Zod-validated
162
- const item = await ctx.state.get<Item>('item:123'); // T | null (type assertion)
163
- const safe = await ctx.state.get('item:123', ItemSchema); // T | null (runtime validated)
162
+ const item = await ctx.state.get<Item>('item/123'); // T | null (type assertion)
163
+ const safe = await ctx.state.get('item/123', ItemSchema); // T | null (runtime validated)
164
164
 
165
165
  // Delete
166
- await ctx.state.delete('item:123');
166
+ await ctx.state.delete('item/123');
167
167
 
168
168
  // Batch operations
169
- const values = await ctx.state.getMany<Item>(['item:1', 'item:2']); // Map<string, T>
169
+ const values = await ctx.state.getMany<Item>(['item/1', 'item/2']); // Map<string, T>
170
170
  await ctx.state.setMany(new Map([['a', 1], ['b', 2]]));
171
- const deleted = await ctx.state.deleteMany(['item:1', 'item:2']); // number
171
+ const deleted = await ctx.state.deleteMany(['item/1', 'item/2']); // number
172
172
 
173
173
  // List with prefix + pagination
174
- const page = await ctx.state.list('item:', { cursor, limit: 20 });
174
+ const page = await ctx.state.list('item/', { cursor, limit: 20 });
175
175
  for (const { key, value } of page.items) { /* ... */ }
176
176
  if (page.cursor) { /* more pages available */ }
177
177
  ```
@@ -180,6 +180,7 @@ if (page.cursor) { /* more pages available */ }
180
180
 
181
181
  - Throws `McpError(InvalidRequest)` if `tenantId` is missing. Won't happen in stdio (any auth mode) or HTTP+`MCP_AUTH_MODE=none` — both default to `'default'`. Can happen in HTTP+`MCP_AUTH_MODE=jwt`/`oauth` when the token lacks a `tid` claim (intentional fail-closed: distinct authenticated callers must not silently share state).
182
182
  - Keys are tenant-prefixed internally; handlers never need to namespace manually.
183
+ - **Key charset:** `^[a-zA-Z0-9_.\-/]+$`, 1024 chars max, no `..`. Slashes are the namespace separator — a colon (`item:123`) throws `McpError(ValidationError)` on every call. The rule covers `list` prefixes and every key in a batch operation. `createMockContext().state` enforces it identically, so an illegal key fails in the test rather than in a deployment.
183
184
  - **Workers persistence:** The `in-memory` provider loses data on cold starts. Use `cloudflare-kv`, `cloudflare-r2`, or `cloudflare-d1` for durable storage in Workers.
184
185
 
185
186
  ---
@@ -237,14 +238,14 @@ import { invalidRequest } from '@cyanheads/mcp-ts-core/errors';
237
238
  if (!ctx.sessionId) {
238
239
  throw invalidRequest('Session required for this operation.');
239
240
  }
240
- await ctx.state.set(`session:${ctx.sessionId}:${baseKey}`, value);
241
+ await ctx.state.set(`session/${ctx.sessionId}/${baseKey}`, value);
241
242
  ```
242
243
 
243
244
  **Lax — fall back to tenant-shared key:**
244
245
 
245
246
  ```ts
246
247
  const sessionKey = ctx.sessionId
247
- ? `session:${ctx.sessionId}:${baseKey}`
248
+ ? `session/${ctx.sessionId}/${baseKey}`
248
249
  : baseKey;
249
250
  await ctx.state.set(sessionKey, value);
250
251
  ```
@@ -253,7 +254,7 @@ await ctx.state.set(sessionKey, value);
253
254
 
254
255
  ### Behavior notes
255
256
 
256
- - **Not a tenant boundary.** `ctx.state` is still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix with `session:${ctx.sessionId}:` as shown above.
257
+ - **Not a tenant boundary.** `ctx.state` is still tenant-scoped. Building session-scoped state is the consumer's responsibility — prefix with `session/${ctx.sessionId}/` as shown above.
257
258
  - **Auto-task tools.** `task: true` handlers run in a detached background context with no session attachment — `ctx.sessionId` is always `undefined` regardless of mode.
258
259
  - **Worker bundle.** Workers use the same HTTP transport plumbing; session behavior matches Node HTTP.
259
260
 
@@ -603,6 +604,7 @@ ctx.enrich.truncated({ shown, cap, ceiling?, guidance? }): void
603
604
  | Service usage | Services accepting `ctx: Context` can call `ctx.enrich(...)`; the value reaches `structuredContent` exactly as if the handler had. |
604
605
  | `format-parity` | Enrichment lives outside `output`, so the `format-parity` lint never requires it in `format()`. |
605
606
  | Trailer rendering | Per field: kind-tag if set (notice/total/echo/delta), else the definition's `enrichmentTrailer.render`/`label`, else `**key:** value` (objects/arrays `JSON.stringify`'d). A structured field with no `render` errors under `enrichment-trailer-render` — supply one so it renders as markdown; `structuredContent` keeps the full value regardless. |
607
+ | Trailer layout | One field per line. A field whose last line opens a block quote or a list item (`notice`, or a `render` ending in `>`, `-`, `*`, `1.`) gets a blank line after it, so the next field renders as its own block instead of being folded into that container by CommonMark lazy continuation. |
606
608
 
607
609
  ### `ctx.enrich.truncated()` — capped-list disclosure
608
610
 
@@ -4,7 +4,7 @@ description: >
4
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
- version: "1.8"
7
+ version: "1.9"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -43,8 +43,8 @@ Grouped by family. Jump to any rule ID via its anchor.
43
43
  | Family | Rules | Section |
44
44
  |:-------|:------|:--------|
45
45
  | Definition | `definition-invalid` | [Definition rules](#definition-rules) |
46
- | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed` | [Format parity](#format-parity) |
47
- | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable` | [Schema rules](#schema-rules) |
46
+ | Format parity | `format-parity`, `format-parity-threw`, `format-parity-walk-failed`, `format-parity-depth-limit` | [Format parity](#format-parity) |
47
+ | Schema | `schema-is-object`, `describe-on-fields`, `schema-serializable`, `schema-unsatisfiable` | [Schema rules](#schema-rules) |
48
48
  | Portability | `schema-format-portability`, `schema-anyof-needs-type`, `schema-no-discriminator-keyword`, `schema-no-defs`, `schema-dialect-tag` | [Portability rules](#portability-rules) |
49
49
  | Names | `name-required`, `name-format`, `name-unique` | [Name rules](#name-rules) |
50
50
  | Tools | `description-required`, `handler-required`, `auth-type`, `auth-scope-format`, `annotation-type`, `annotation-coherence`, `meta-ui-type`, `meta-ui-resource-uri-required`, `meta-ui-resource-uri-scheme`, `app-tool-resource-pairing`, `canvas-consumer-missing` | [Tool rules](#tool-rules) |
@@ -75,6 +75,19 @@ Fires when a `tools`, `resources`, or `prompts` array passed to `validateDefinit
75
75
 
76
76
  Why this family exists: different MCP clients forward different surfaces of a tool response to the model. Claude Code reads `structuredContent` (from your handler's return value, typed by `output`). Claude Desktop reads `content[]` (from your `format()` function). Every field must be visible on both surfaces or one class of client sees less than another. The linter enforces this by synthesizing a sample value where every leaf is a uniquely identifiable sentinel, calling `format()` once, then verifying each sentinel (or its key name, for permissive types like booleans) appears in the rendered text.
77
77
 
78
+ **How leaves are matched.** Two strategies, picked by leaf type:
79
+
80
+ | Leaf type | Sentinel | Match |
81
+ |:--|:--|:--|
82
+ | string | `MCPPARITY<path>` — alphanumeric only | substring, anywhere in the rendered text |
83
+ | number / int / bigint | a large distinctive integer | substring, retried against locale digit grouping (`900,000,001` → `900000001`) |
84
+ | boolean, enum member, literal, unrecognized type | the value the schema dictates (`true`, the first enum member, the literal) | **delimited token** — must not be flanked by another alphanumeric or `_`; falls back to the field's key name as a whole word or camelCase segment |
85
+
86
+ Two consequences worth knowing when writing a `format()`:
87
+
88
+ - **The string sentinel is alphanumeric so escaping does not break it.** `content[]` is markdown carrying upstream text you do not control, so escaping `_`, `*`, `` ` ``, `[`, `<` at the render boundary is correct — and it leaves an alphanumeric probe byte-identical. Markdown escaping, HTML escaping, and URL encoding all pass. You never need to carve an exception into your escape set to keep `lint:mcp` green.
89
+ - **Schema-dictated values must render as their own token.** A required `kind: z.enum(['full', 'outline'])` that `format()` never renders is not satisfied by the letters `full` appearing inside a longer word elsewhere in the output — `case_name_full`, `inactive`, `listing`. Render the field, or render its key name as a label.
90
+
78
91
  ### format-parity
79
92
 
80
93
  **Severity:** error
@@ -125,7 +138,17 @@ Add narrow guards. The linter feeds a synthetic but schema-valid value; if your
125
138
 
126
139
  Fires when the linter cannot walk the output schema to build a synthetic sample (usually because the schema uses an unusual composition the walker doesn't recognize). Parity is not verified for that tool — nothing is broken at runtime, but the check is silently disabled.
127
140
 
128
- **Fix:** inspect the walker error message in the diagnostic. Usually caused by very deep recursion, custom Zod extensions, or mixing Zod 3 and 4 schema internals. File an issue against `@cyanheads/mcp-ts-core` with the schema shape — this is a linter gap, not user error.
141
+ **Fix:** inspect the walker error message in the diagnostic. Usually caused by custom Zod extensions or mixing Zod 3 and 4 schema internals. File an issue against `@cyanheads/mcp-ts-core` with the schema shape — this is a linter gap, not user error.
142
+
143
+ ### format-parity-depth-limit
144
+
145
+ **Severity:** warning
146
+
147
+ Fires when an output field is nested deeper than the sentinel walker's depth limit (8). Everything at and below that path was **not evaluated** — parity for the subtree is unknown, not verified. Four array hops from the output root is enough to reach the limit, so it turns up on ordinary shapes, not just pathological ones.
148
+
149
+ The bound exists because every array / union / record hop multiplies the variant set, and a self-referential schema would otherwise recurse forever. What changed is the reporting: an unevaluated subtree used to be indistinguishable from a field that resolved to nothing, so it read as a pass.
150
+
151
+ **Fix:** flatten the output shape so the field sits within the limit, or verify by hand that `format()` renders it (and treat the warning as the standing reminder that the linter is not covering it).
129
152
 
130
153
  ---
131
154
 
@@ -200,6 +223,25 @@ z.string().describe('ISO 8601 timestamp, e.g., 2026-04-20T12:00:00Z')
200
223
 
201
224
  Parse the string to a `Date` inside the handler if you need one.
202
225
 
226
+ ### schema-unsatisfiable
227
+
228
+ **Severity:** error
229
+
230
+ Fires when a node in the emitted JSON Schema describes an **empty value set** — a field no value can ever satisfy. Nothing downstream reports this: the tool registers, the schema is forwarded to the model, and the argument simply can never be populated.
231
+
232
+ Evaluated on the emitted schema rather than on the Zod schema, because the two disagree in exactly the case that matters most.
233
+
234
+ | What you wrote | What is emitted |
235
+ |:--|:--|
236
+ | `z.enum([1, 2, 3, 4, 5])` — a numeric array handed to a string-only constructor | `{"type": "string", "enum": []}` |
237
+ | `z.enum([])` | `{"type": "string", "enum": []}` |
238
+ | `z.union([])` | `{"anyOf": []}` |
239
+ | `z.never()` | `{"not": {}}` |
240
+
241
+ **Fix:** for a closed set of non-string values, use a multi-value literal — `z.literal([1, 2, 3, 4, 5])` emits `{"type": "number", "enum": [1, 2, 3, 4, 5]}`. For an empty enum or union, the field has no legal values at all; drop it or give it real members.
242
+
243
+ Not flagged, deliberately: `allOf: []` is vacuously true (matches everything), and empty `required` / `properties` / `prefixItems` are absent constraints rather than impossible ones.
244
+
203
245
  ---
204
246
 
205
247
  ## Portability rules
@@ -818,10 +860,22 @@ Fires when an `enrichmentTrailer` key doesn't match any declared `enrichment` fi
818
860
  **Severity:** warning
819
861
 
820
862
  Fires when a tool:
821
- 1. has a depth-0 input field named `limit`, `per_page`, `page_size`, `max_results`, or `max_items` (case-insensitive; camelCase twins like `perPage`, `maxResults` match too), AND
863
+ 1. has a depth-0 input field whose name is cap-*shaped*, AND
822
864
  2. has at least one depth-0 array-typed `output` field, AND
823
865
  3. declares no truncation disclosure.
824
866
 
867
+ Cap-shaped means, after normalizing camelCase to snake_case (so `maxRecords` and `max_records` are one case):
868
+
869
+ | Shape | Examples |
870
+ |:--|:--|
871
+ | `limit`, `<noun>_limit` / `<noun>Limit` | `limit`, `result_limit`, `resultLimit` |
872
+ | `max_<noun>` / `max<Noun>` | `max_results`, `maxResults`, `max_items`, `maxRecords`, `maxRows` |
873
+ | page-size idioms | `per_page`, `perPage`, `page_size`, `pageSize` |
874
+
875
+ Matched by shape rather than an enumerated list, so a new cap noun is covered on arrival instead of silently disabling the rule for that tool. Deliberately not matched: bare `count`, `size`, `n`, `rows`, `records`, and words that merely begin with the letters (`maximum`).
876
+
877
+ The shape does not distinguish a cap on *how many* from an upper bound on a *value*, so a range filter written as `max_<noun>` — `max_magnitude`, `max_depth_km`, `maxLat`, `max_date` — matches too. On a tool that also returns an array and discloses nothing, that reads as a warning about truncation the tool does not perform. Disclose `totalCount` if the array is paged at all (which silences it honestly), or exempt the tool via `truncationAllowlist` / `MCP_LINT_TRUNCATION_ALLOWLIST`.
878
+
825
879
  **Disclosure-present (rule silent) when** any of the following is true:
826
880
  - The declared `enrichment` shape has a `truncated` or `totalCount` key (`ctx.enrich.truncated()` and `ctx.enrich.total()` satisfy this).
827
881
  - The `output` schema has a depth-0 `truncated` or `totalCount` field.
@@ -4,7 +4,7 @@ description: >
4
4
  Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.6"
7
+ version: "1.8"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -51,7 +51,7 @@ mcpTest('stubs an upstream HTTP boundary', async ({ fetchMock }) => {
51
51
  | Fixture | Type | Per-test? | Notes |
52
52
  |:--------|:-----|:----------|:------|
53
53
  | `ctx` | `Context` | Yes | Fresh `createMockContext()` each test |
54
- | `session` | `MockSession` | Yes | Fresh `{ sessionId, tenantId?, ctx }` from `createMockSession()` |
54
+ | `session` | `MockSession` | Yes | Fresh `{ sessionId, tenantId, ctx }` from `createMockSession()` |
55
55
  | `fetchMock` | `FetchMockHarness` | Yes | Strict fetch fake installed/restored around the requesting test |
56
56
  | `storage` | `StorageService` | Yes | Fresh `createInMemoryStorage()` each test |
57
57
 
@@ -131,8 +131,8 @@ Use `runToolContract(definition, input, { context })` from `/testing` when a cus
131
131
  ```ts
132
132
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
133
133
 
134
- createMockContext() // minimal — ctx.state operations throw without tenantId
135
- createMockContext({ tenantId: 'test-tenant' }) // enables ctx.state (tenant-scoped in-memory storage)
134
+ createMockContext() // working ctx.state on tenant 'default'
135
+ createMockContext({ tenantId: 'test-tenant' }) // explicit tenant scope for ctx.state
136
136
  createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
137
137
  createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
138
138
  createMockContext({ progress: true }) // with task progress (ctx.progress populated)
@@ -147,10 +147,10 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
147
147
  `MockContextOptions` interface:
148
148
 
149
149
  ```ts
150
- interface MockContextOptions {
150
+ interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefined> {
151
151
  auth?: AuthContext;
152
152
  elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
153
- errors?: readonly ErrorContract[];
153
+ errors?: TErrors | undefined;
154
154
  notifyPromptListChanged?: () => void;
155
155
  notifyResourceListChanged?: () => void;
156
156
  notifyResourceUpdated?: (uri: string) => void;
@@ -166,10 +166,10 @@ interface MockContextOptions {
166
166
 
167
167
  | Option | Effect |
168
168
  |:-------|:-------|
169
- | _(none)_ | Minimal context — `ctx.state` operations throw without `tenantId`; `ctx.elicit`/`ctx.progress` are `undefined` |
169
+ | _(none)_ | Working `ctx.state` on tenant `'default'`; `ctx.elicit`/`ctx.progress` are `undefined` |
170
170
  | `auth` | Sets `ctx.auth` for scope-checking tests |
171
171
  | `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
172
- | `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly. |
172
+ | `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly; the return type narrows to `HandlerContext<ReasonOf<…>>`, so the context is assignable to that definition's handler parameter. |
173
173
  | `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
174
174
  | `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
175
175
  | `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
@@ -178,9 +178,28 @@ interface MockContextOptions {
178
178
  | `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
179
179
  | `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
180
180
  | `signal` | Overrides `ctx.signal` — useful for cancellation testing |
181
- | `tenantId` | Sets `ctx.tenantId` and enables `ctx.state` operations with in-memory storage |
181
+ | `tenantId` | Scopes `ctx.state` to a specific tenant. Defaults to `'default'` — the value stdio (and HTTP with `MCP_AUTH_MODE=none`) resolves |
182
182
  | `uri` | Sets `ctx.uri` for resource handler testing |
183
183
 
184
+ ### Mock state
185
+
186
+ `ctx.state` is a real `StorageService` over an `InMemoryProvider` — the production storage path, not a `Map`. A test therefore sees the same rules a deployed server enforces:
187
+
188
+ - **Keys** match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`. Colons are rejected, so `cache:v1:abc` throws `McpError(ValidationError)` in the test exactly as it would in a deployment; use `cache/v1/abc`.
189
+ - **TTL** is honored. An entry written with `{ ttl: 30 }` reads back as `null` once 30 seconds elapse — drive the clock with `vi.useFakeTimers()` to assert expiry.
190
+ - **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
191
+ - **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
192
+
193
+ ```ts
194
+ const ctx = createMockContext();
195
+
196
+ await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
197
+ await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
198
+ await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
199
+ ```
200
+
201
+ Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
202
+
184
203
  ### Mock progress
185
204
 
186
205
  When `progress: true`, `ctx.progress` is a real state-tracking object — not `vi.fn()` spies. It maintains internal state accessible via inspection properties:
@@ -396,7 +415,7 @@ describe('myTool with service', () => {
396
415
 
397
416
  - Re-init services with `initMyService()` (or equivalent) in `beforeEach` when tests share a module-level singleton.
398
417
  - Vitest runs test files in separate workers — parallel file execution is safe by default.
399
- - Use `createMockContext({ tenantId })` whenever the handler accesses `ctx.state` — omitting `tenantId` causes `ctx.state` to throw.
418
+ - Pass `createMockContext({ tenantId })` when a test needs a specific tenant; omitting it scopes state to `'default'`, not to a broken state surface.
400
419
 
401
420
  ---
402
421
 
@@ -503,3 +522,5 @@ it('survives fuzz testing', async () => {
503
522
  | `adversarialArbitrary()` / `ADVERSARIAL_STRINGS` | Targeted injection sets (prototype pollution probes, control characters, oversized payloads). |
504
523
 
505
524
  `FuzzOptions`: `numRuns` (default 50), `numAdversarial` (default 30), `seed` (reproducibility), `timeout` (per-call ms, default 5000), `ctx` (`MockContextOptions` for stateful handlers).
525
+
526
+ `report.leaks` looks for a stack frame or a server-side path in what a client can observe — the `code`, `message`, and `data` of the thrown `McpError`. Strings the input itself supplied are removed before that check, so naming the offending value in error data (`throw validationError(msg, { key })`) never registers as a leak: the client sent those bytes and learns nothing from seeing them again.
@@ -4,7 +4,7 @@ description: >
4
4
  API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "2.5"
7
+ version: "2.6"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -15,6 +15,8 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
15
15
 
16
16
  **Tier 3** = optional peer dependency. Install as needed (e.g., `bun add js-yaml`). All Tier 3 methods are **async** (lazy-load deps on first call).
17
17
 
18
+ **Context parameters.** Every helper below that takes a `context` accepts the handler `Context` as well as a `RequestContext` bag — pass `ctx` straight through, no slicing.
19
+
18
20
  ## References
19
21
 
20
22
  | Reference | Path | Covers |
@@ -29,7 +31,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
29
31
 
30
32
  | Export | API | Notes |
31
33
  |:-------|:----|:------|
32
- | `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), and `signal?: AbortSignal` (external cancellation). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major). 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. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
34
+ | `fetchWithTimeout` | `(url, timeoutMs, context, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `timeoutMs` bounds the **whole exchange**: on a 2xx carrying a body the returned `Response` is a passthrough wrapper that keeps the deadline armed until the body closes, errors, or is cancelled, so a stalled stream rejects the caller's `.text()`/`.json()` with the same `Timeout` error the header phase raises. `status`, `statusText`, `headers`, `url`, `redirected`, and `type` carry across the wrapper; the original body is locked by it, and bodyless/null-body responses (HEAD, 204/205/304) come back untouched. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean`, `expectedStatuses?: number[]` (listed non-2xx statuses logged at `debug` not `error`, still thrown), `errorBodyLimit?: number` (bytes of a non-2xx body kept, default `500`), and `signal?: AbortSignal` (external cancellation). On a non-2xx, `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values; consolidating in a future major); a body over `errorBodyLimit` is captured from both ends — 40% head, 60% tail, joined by `…[N bytes elided]…` — so a diagnostic behind a boilerplate preamble survives the cap, while a body still streaming at the 16 KiB scan ceiling stays head-only with a trailing `…`. 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. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
33
35
  | `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. Honors an upstream `Retry-After` on `data.retryAfter` (delta-seconds or HTTP-date) over exponential backoff, capped at `maxDelayMs`; a requested wait beyond the cap fails fast rather than sleeping. 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
36
  | `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`. `error.data` carries `status`/`body` plus the legacy `statusCode`/`responseBody` aliases (identical values), so a consumer can classify either helper's error without knowing which raised it. 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
37
  | `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. |
@@ -10,8 +10,9 @@ All parsers are **Tier 3** — lazy-load their peer dependency on first call. Al
10
10
 
11
11
  - Singleton instances exported alongside classes
12
12
  - `<think>...</think>` blocks at the start of input are automatically stripped and logged at `debug` level (except `dateParser` and `pdfParser`)
13
- - All `context?: RequestContext` parameters are optional (synthetic context created if omitted)
14
- - Errors throw `McpError` — never return error values
13
+ - Every `context?` parameter is optional (synthetic context created if omitted) and accepts the handler `Context` as well as a `RequestContext` bag
14
+ - Input budgets are opt-in: a parser is unbounded unless the caller passes `maxBytes`, which then rejects an over-budget input with `ValidationError` (`reason: 'parser_input_too_large'`). `DEFAULT_TEXT_PARSER_MAX_BYTES` (1 MiB) and `DEFAULT_BINARY_PARSER_MAX_BYTES` (25 MiB) are exported as starting points, not applied defaults
15
+ - Errors throw `McpError` — never return error values. The message is `<summary>: <library message>`, so it carries the underlying parser's diagnostic; `data` carries only `{ reason }`, and the input sample and stack stay on `cause`
15
16
 
16
17
  ---
17
18
 
@@ -4,7 +4,7 @@ description: >
4
4
  Post-init orientation for an MCP server built on @cyanheads/mcp-ts-core. Use after running `@cyanheads/mcp-ts-core init` to understand the project structure, conventions, and skill sync model. Also use when onboarding to an existing project for the first time.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.8"
7
+ version: "1.9"
8
8
  audience: external
9
9
  type: workflow
10
10
  ---
@@ -27,8 +27,8 @@ What `init` actually creates:
27
27
  CLAUDE.md # Agent protocol — Claude Code
28
28
  AGENTS.md # Agent protocol — other agents (Codex, Cursor, etc.)
29
29
  package.json # Starter deps + scripts (placeholders substituted on init)
30
- tsconfig.json # TypeScript config
31
- tsconfig.build.json # Build-only TS config
30
+ tsconfig.json # Typecheck config — covers src/ and tests/, emits nothing
31
+ tsconfig.build.json # Build config — emits src/ to dist/, tests excluded
32
32
  vitest.config.ts # Test runner config
33
33
  biome.json # Lint + format config
34
34
  devcheck.config.json # Which devcheck steps to run
@@ -57,6 +57,9 @@ tests/
57
57
  tools/echo.tool.test.ts # Starter tests (one per echo definition)
58
58
  resources/echo.resource.test.ts
59
59
  prompts/echo.prompt.test.ts
60
+ smoke/definitions.smoke.test.ts # Every shipped definition executed once
61
+ integration/echo-contract.int.test.ts # The echo tool driven through the production surfaces
62
+ fuzz/echo-tool.fuzz.test.ts # Property-based coverage, via the fast-check dev dep
60
63
  ```
61
64
 
62
65
  Add these as needed:
@@ -4,7 +4,7 @@ description: >
4
4
  Catalog of reusable response- and data-shaping techniques for MCP servers built on `@cyanheads/mcp-ts-core` — overflow handling, payload shaping, retrieval patterns. Use when a tool's payload is too large, awkwardly shaped, or expensive to retrieve and you want a proven pattern instead of inventing one. Each technique has a self-contained reference under `references/`.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "0.2"
7
+ version: "0.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -84,7 +84,7 @@ export const getLabel = tool('get_label', {
84
84
 
85
85
  - `budget` — serialized-byte threshold (default `DEFAULT_OUTLINE_BUDGET_BYTES`). A helper argument, **not** an env var: a deploy-tunable threshold would drift a tool's output *shape* across environments.
86
86
  - `extract` — custom section extractor. Default: one section per top-level key, sized by `JSON.stringify(value).length`. Override only when "section" means something other than a top-level key.
87
- - `notice` — custom re-call notice builder. Default names the three largest sections as examples.
87
+ - `notice` — custom re-call notice builder, called as `(sections, budget)`. The default's worked example names the **largest section that fits the budget**, with its byte size inline; when no single section fits it says so and points at narrowing the request instead of naming a section. Naming the largest sections would hand the agent the most expensive retrieval available — the one most likely to blow the same budget the outline exists to enforce.
88
88
 
89
89
  The flow:
90
90
 
@@ -93,6 +93,8 @@ The flow:
93
93
  3. **Over budget, ≥ 2 sections** → the outline (sections sorted largest-first). The agent re-calls with `sections: [...]`.
94
94
  4. **Over budget, < 2 sections** → `full` anyway (nothing to pick between). A single section that *alone* exceeds budget is a known limitation — sub-section outlining is out of scope.
95
95
 
96
+ The budget bounds the **disclosure**, not the selection. `selectSections` returns whatever the agent named, so a selection over several sections — or one section larger than the budget — comes back whole. That is deliberate: the agent asked for those sections by name, and truncating the answer is the thing this technique exists to avoid. The default notice reports each example's size so the selection can be sized before it is made.
97
+
96
98
  ## Re-retrieval — why the selection call is stateless
97
99
 
98
100
  The re-call is **self-contained**, so nothing is stored between the outline call and the selection call:
@@ -104,7 +104,7 @@ export const itemData = resource('inventory://{itemId}', {
104
104
  params: z.object({ itemId: z.string().describe('Item identifier') }),
105
105
  auth: ['inventory:read'],
106
106
  async handler(params, ctx) {
107
- const item = await ctx.state.get(`item:${params.itemId}`);
107
+ const item = await ctx.state.get(`item/${params.itemId}`);
108
108
  if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
109
109
  return item;
110
110
  },
@@ -104,7 +104,7 @@ export const itemData = resource('inventory://{itemId}', {
104
104
  params: z.object({ itemId: z.string().describe('Item identifier') }),
105
105
  auth: ['inventory:read'],
106
106
  async handler(params, ctx) {
107
- const item = await ctx.state.get(`item:${params.itemId}`);
107
+ const item = await ctx.state.get(`item/${params.itemId}`);
108
108
  if (!item) throw notFound(`Item ${params.itemId} not found`, { itemId: params.itemId });
109
109
  return item;
110
110
  },
@@ -51,8 +51,13 @@ COPY package.json bun.lock ./
51
51
 
52
52
  # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
53
53
  # that are not needed in the final production image.
54
+ # `--omit=peer` drops the framework's optional peer tiers (test runner, service
55
+ # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
56
+ # actually imports belongs in its own `dependencies`, so nothing needed at
57
+ # runtime is lost. The OTEL step below carries the same flag — without it, that
58
+ # install re-resolves the graph and pulls every optional peer back in.
54
59
  RUN --mount=type=cache,target=/root/.bun/install/cache \
55
- bun install --production --frozen-lockfile --ignore-scripts
60
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts
56
61
 
57
62
  # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
58
63
  # These are not bundled by default to keep the base image lean. Enable at build time
@@ -60,7 +65,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
60
65
  ARG OTEL_ENABLED=true
61
66
  RUN --mount=type=cache,target=/root/.bun/install/cache \
62
67
  if [ "$OTEL_ENABLED" = "true" ]; then \
63
- bun add --omit=dev --ignore-scripts @hono/otel \
68
+ bun add --omit=dev --omit=peer --ignore-scripts @hono/otel \
64
69
  @opentelemetry/instrumentation-http \
65
70
  @opentelemetry/exporter-metrics-otlp-http \
66
71
  @opentelemetry/exporter-trace-otlp-http \
@@ -1,4 +1,9 @@
1
1
  {
2
2
  "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "noEmit": false
6
+ },
7
+ "include": ["src/**/*"],
3
8
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
4
9
  }
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "extends": "@cyanheads/mcp-ts-core/tsconfig.base.json",
3
3
  "compilerOptions": {
4
- "rootDir": "src",
4
+ "rootDir": ".",
5
5
  "outDir": "dist",
6
+ "noEmit": true,
7
+ "tsBuildInfoFile": ".tsbuildinfo",
6
8
  "paths": {
7
9
  "@/*": ["./src/*"]
8
10
  }
9
11
  },
10
- "include": ["src/**/*"],
12
+ "include": ["src/**/*", "tests/**/*"],
11
13
  "exclude": ["node_modules", "dist"]
12
14
  }
@@ -70,6 +70,7 @@
70
70
  "@types/node": "26.1.1",
71
71
  "@vitest/coverage-istanbul": "4.1.10",
72
72
  "depcheck": "^1.4.7",
73
+ "fast-check": "^4.9.0",
73
74
  "ignore": "^7.0.6",
74
75
  "tsc-alias": "^1.9.1",
75
76
  "typescript": "^7.0.2",
@@ -7,9 +7,10 @@ import { describe, expect, it } from 'vitest';
7
7
  import { echoPrompt } from '@/mcp-server/prompts/definitions/echo.prompt.js';
8
8
 
9
9
  describe('echoPrompt', () => {
10
- it('generates a user message with the echoed text', () => {
11
- const args = echoPrompt.args.parse({ message: 'hello world' });
12
- const messages = echoPrompt.generate(args);
10
+ it('generates a user message with the echoed text', async () => {
11
+ const args = echoPrompt.args!.parse({ message: 'hello world' });
12
+ // `generate` may be async — await covers both shapes.
13
+ const messages = await echoPrompt.generate(args);
13
14
  expect(messages).toHaveLength(1);
14
15
  expect(messages[0]).toMatchObject({
15
16
  role: 'user',
@@ -10,13 +10,21 @@ import { echoResource } from '@/mcp-server/resources/definitions/echo.resource.j
10
10
  describe('echoResource', () => {
11
11
  it('echoes the message from params', async () => {
12
12
  const ctx = createMockContext();
13
- const params = echoResource.params.parse({ message: 'hello world' });
13
+ const params = echoResource.params!.parse({ message: 'hello world' });
14
14
  const result = await echoResource.handler(params, ctx);
15
15
  expect(result).toEqual({ message: 'hello world' });
16
16
  });
17
17
 
18
- it('lists available resources', () => {
19
- const listing = echoResource.list!();
18
+ it('lists available resources', async () => {
19
+ // `list` receives the SDK's request-handler extra, not a Context, and may be
20
+ // async — a minimal literal is enough for a listing that ignores it.
21
+ const extra = {
22
+ signal: new AbortController().signal,
23
+ requestId: 'test',
24
+ sendNotification: () => Promise.resolve(),
25
+ sendRequest: () => Promise.resolve({} as never),
26
+ };
27
+ const listing = await echoResource.list!(extra);
20
28
  expect(listing.resources).toHaveLength(1);
21
29
  expect(listing.resources[0]).toMatchObject({
22
30
  uri: 'echo://hello',