@cyanheads/mcp-ts-core 0.13.8 → 0.13.10

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 (180) hide show
  1. package/AGENTS.md +51 -24
  2. package/CLAUDE.md +51 -24
  3. package/README.md +10 -10
  4. package/changelog/0.13.x/0.13.10.md +118 -0
  5. package/changelog/0.13.x/0.13.9.md +113 -0
  6. package/dist/config/index.d.ts +3 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +31 -9
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/core/app.d.ts +6 -3
  11. package/dist/core/app.d.ts.map +1 -1
  12. package/dist/core/app.js +21 -5
  13. package/dist/core/app.js.map +1 -1
  14. package/dist/core/context.d.ts +114 -21
  15. package/dist/core/context.d.ts.map +1 -1
  16. package/dist/core/context.js +40 -0
  17. package/dist/core/context.js.map +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/index.js.map +1 -1
  21. package/dist/core/serverManifest.d.ts +6 -0
  22. package/dist/core/serverManifest.d.ts.map +1 -1
  23. package/dist/core/serverManifest.js +6 -0
  24. package/dist/core/serverManifest.js.map +1 -1
  25. package/dist/core/worker.d.ts +6 -0
  26. package/dist/core/worker.d.ts.map +1 -1
  27. package/dist/core/worker.js +1 -0
  28. package/dist/core/worker.js.map +1 -1
  29. package/dist/linter/rules/error-contract-rules.d.ts +3 -44
  30. package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
  31. package/dist/linter/rules/error-contract-rules.js +8 -144
  32. package/dist/linter/rules/error-contract-rules.js.map +1 -1
  33. package/dist/linter/rules/index.d.ts +1 -1
  34. package/dist/linter/rules/index.d.ts.map +1 -1
  35. package/dist/linter/rules/index.js +1 -1
  36. package/dist/linter/rules/index.js.map +1 -1
  37. package/dist/linter/rules/resource-rules.d.ts.map +1 -1
  38. package/dist/linter/rules/resource-rules.js +1 -2
  39. package/dist/linter/rules/resource-rules.js.map +1 -1
  40. package/dist/linter/rules/tool-rules.d.ts +2 -1
  41. package/dist/linter/rules/tool-rules.d.ts.map +1 -1
  42. package/dist/linter/rules/tool-rules.js +37 -3
  43. package/dist/linter/rules/tool-rules.js.map +1 -1
  44. package/dist/mcp-server/handlerContext.d.ts +26 -13
  45. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  46. package/dist/mcp-server/handlerContext.js +32 -17
  47. package/dist/mcp-server/handlerContext.js.map +1 -1
  48. package/dist/mcp-server/inputRequired.d.ts +133 -12
  49. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  50. package/dist/mcp-server/inputRequired.js +192 -20
  51. package/dist/mcp-server/inputRequired.js.map +1 -1
  52. package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
  53. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  54. package/dist/mcp-server/prompts/prompt-registration.js +49 -11
  55. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  56. package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
  57. package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
  58. package/dist/mcp-server/resources/resource-registration.js +6 -4
  59. package/dist/mcp-server/resources/resource-registration.js.map +1 -1
  60. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
  61. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  62. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
  63. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  64. package/dist/mcp-server/server.d.ts +9 -0
  65. package/dist/mcp-server/server.d.ts.map +1 -1
  66. package/dist/mcp-server/server.js +14 -13
  67. package/dist/mcp-server/server.js.map +1 -1
  68. package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
  69. package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/tool-registration.js +9 -5
  71. package/dist/mcp-server/tools/tool-registration.js.map +1 -1
  72. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
  73. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  74. package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
  75. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  76. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +40 -19
  77. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  78. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +387 -130
  79. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  80. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  81. package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
  82. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  83. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  84. package/dist/mcp-server/transports/http/httpTransport.js +65 -9
  85. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  86. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
  87. package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
  88. package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
  89. package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
  90. package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
  91. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  92. package/dist/services/canvas/core/CanvasRegistry.js +7 -3
  93. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  94. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
  95. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  96. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
  97. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  98. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
  99. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  100. package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
  101. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  102. package/dist/services/mirror/core/defineMirror.d.ts +1 -0
  103. package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
  104. package/dist/services/mirror/core/defineMirror.js +1 -0
  105. package/dist/services/mirror/core/defineMirror.js.map +1 -1
  106. package/dist/testing/index.d.ts +17 -2
  107. package/dist/testing/index.d.ts.map +1 -1
  108. package/dist/testing/index.js +21 -7
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/types-global/errors.d.ts +18 -15
  111. package/dist/types-global/errors.d.ts.map +1 -1
  112. package/dist/utils/index.d.ts +1 -1
  113. package/dist/utils/index.d.ts.map +1 -1
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
  116. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  117. package/dist/utils/internal/error-handler/errorHandler.js +9 -7
  118. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  119. package/dist/utils/internal/error-handler/types.d.ts +3 -1
  120. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  121. package/dist/utils/internal/performance.d.ts +4 -2
  122. package/dist/utils/internal/performance.d.ts.map +1 -1
  123. package/dist/utils/internal/performance.js +8 -6
  124. package/dist/utils/internal/performance.js.map +1 -1
  125. package/dist/utils/internal/telemetryMessages.d.ts +0 -1
  126. package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
  127. package/dist/utils/internal/telemetryMessages.js +0 -1
  128. package/dist/utils/internal/telemetryMessages.js.map +1 -1
  129. package/dist/utils/network/pacer.d.ts +38 -5
  130. package/dist/utils/network/pacer.d.ts.map +1 -1
  131. package/dist/utils/network/pacer.js +87 -25
  132. package/dist/utils/network/pacer.js.map +1 -1
  133. package/dist/utils/telemetry/attributes.d.ts +10 -5
  134. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  135. package/dist/utils/telemetry/attributes.js +10 -5
  136. package/dist/utils/telemetry/attributes.js.map +1 -1
  137. package/framework-skills/add-app-tool/SKILL.md +3 -3
  138. package/framework-skills/add-export/SKILL.md +5 -16
  139. package/framework-skills/add-prompt/SKILL.md +7 -3
  140. package/framework-skills/add-resource/SKILL.md +7 -5
  141. package/framework-skills/add-service/SKILL.md +3 -12
  142. package/framework-skills/add-test/SKILL.md +6 -3
  143. package/framework-skills/add-tool/SKILL.md +40 -42
  144. package/framework-skills/api-auth/SKILL.md +2 -2
  145. package/framework-skills/api-canvas/SKILL.md +17 -8
  146. package/framework-skills/api-config/SKILL.md +5 -4
  147. package/framework-skills/api-context/SKILL.md +168 -42
  148. package/framework-skills/api-errors/SKILL.md +48 -51
  149. package/framework-skills/api-linter/SKILL.md +30 -35
  150. package/framework-skills/api-mirror/SKILL.md +2 -1
  151. package/framework-skills/api-telemetry/SKILL.md +14 -10
  152. package/framework-skills/api-testing/SKILL.md +43 -11
  153. package/framework-skills/api-utils/SKILL.md +2 -2
  154. package/framework-skills/api-workers/SKILL.md +3 -1
  155. package/framework-skills/design-mcp-server/SKILL.md +6 -6
  156. package/framework-skills/field-test/SKILL.md +5 -5
  157. package/framework-skills/git-wrapup/SKILL.md +8 -6
  158. package/framework-skills/orchestrations/SKILL.md +7 -6
  159. package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
  160. package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
  161. package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
  162. package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
  163. package/framework-skills/polish-docs-meta/SKILL.md +4 -4
  164. package/framework-skills/release-and-publish/SKILL.md +8 -6
  165. package/framework-skills/release-pr-review/SKILL.md +38 -24
  166. package/framework-skills/report-issue-framework/SKILL.md +7 -5
  167. package/framework-skills/report-issue-local/SKILL.md +8 -6
  168. package/framework-skills/security-pass/SKILL.md +14 -13
  169. package/package.json +6 -5
  170. package/scripts/devcheck.ts +7 -6
  171. package/scripts/install-otel.ts +84 -0
  172. package/scripts/lint-mcp.ts +87 -27
  173. package/scripts/lint-packaging.ts +226 -4
  174. package/scripts/release-github.ts +117 -5
  175. package/templates/.env.example +2 -0
  176. package/templates/AGENTS.md +5 -4
  177. package/templates/CLAUDE.md +5 -4
  178. package/templates/Dockerfile +67 -50
  179. package/templates/_.mcpbignore +2 -0
  180. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
@@ -18,6 +18,15 @@
18
18
  * The framework itself has no `manifest.json`/`.mcpb`, so the attach path is
19
19
  * skipped here but scaffolded servers that do have a manifest get the full flow.
20
20
  *
21
+ * Before any `gh` call it validates the tag — `--notes-from-tag` publishes the
22
+ * message verbatim as the release body, so a malformed tag becomes a malformed
23
+ * public release. `--check` runs only that validation, for use right after
24
+ * `git tag -a` and before the tag is pushed. The rules are the ones the
25
+ * `release-and-publish` skill states for the annotation: an annotated tag, a
26
+ * subject of at most 72 characters with no version and no `;`, flat bullets
27
+ * with no section headers, no signature block leaked into the body, and the
28
+ * `[CHANGELOG v<version>](…)` link as the final line.
29
+ *
21
30
  * @module scripts/release-github
22
31
  *
23
32
  * @example
@@ -25,6 +34,10 @@
25
34
  * // bun run release:github
26
35
  *
27
36
  * @example
37
+ * // Validate the tag annotation only (exit 1 on a violation, no gh calls):
38
+ * // bun run release:github -- --check
39
+ *
40
+ * @example
28
41
  * // Dry-run — print the command that would be executed without running it:
29
42
  * // bun run release:github -- --dry-run
30
43
  */
@@ -33,8 +46,86 @@ import { spawnSync } from 'node:child_process';
33
46
  import { existsSync, readFileSync } from 'node:fs';
34
47
  import { resolve } from 'node:path';
35
48
  import process from 'node:process';
49
+ import { fileURLToPath } from 'node:url';
36
50
 
37
51
  const DRY_RUN = process.argv.includes('--dry-run');
52
+ const CHECK_ONLY = process.argv.includes('--check');
53
+
54
+ /** A tag subject longer than this reads as a digest, not a release title. */
55
+ const MAX_SUBJECT_LENGTH = 72;
56
+
57
+ /**
58
+ * Section headers belong in the changelog entry, never in the tag body: a
59
+ * markdown heading, a Keep a Changelog section name, or any other line that is
60
+ * not a bullet and ends in a colon (`Dependency bumps:`, `Highlights:`).
61
+ */
62
+ const SECTION_HEADER =
63
+ /^(?:#{1,6}\s.*|(?:Added|Changed|Deprecated|Removed|Fixed|Security|Dependencies)\s*:?|[^\s\-*+[].*:)$/;
64
+
65
+ /** The parts of an annotated tag that become the GitHub Release title and body. */
66
+ export interface TagMessage {
67
+ body: string;
68
+ /** `git for-each-ref %(objecttype)` — `tag` for an annotated tag, `commit` for a lightweight one. */
69
+ objectType: string;
70
+ subject: string;
71
+ }
72
+
73
+ /**
74
+ * Validates a tag annotation against the release-body rules. Returns one
75
+ * message per violation; an empty array means the tag is publishable.
76
+ */
77
+ export function checkTagMessage(tag: TagMessage, version: string): string[] {
78
+ if (tag.objectType !== 'tag') {
79
+ return [
80
+ `v${version} is a lightweight tag — recreate it annotated: git tag -a v${version} -F <file>`,
81
+ ];
82
+ }
83
+
84
+ const errors: string[] = [];
85
+ const { subject } = tag;
86
+ if (subject.length > MAX_SUBJECT_LENGTH) {
87
+ errors.push(
88
+ `subject is ${subject.length} characters — keep it one short theme (≤${MAX_SUBJECT_LENGTH}); the bullets carry the digest`,
89
+ );
90
+ }
91
+ if (subject.includes(version)) {
92
+ errors.push(
93
+ `subject contains the version "${version}" — GitHub prepends "v${version}:" to the title`,
94
+ );
95
+ }
96
+ if (subject.includes(';')) {
97
+ errors.push('subject contains ";" — one theme, not a list of changes');
98
+ }
99
+
100
+ if (tag.body.includes('-----BEGIN')) {
101
+ errors.push(
102
+ 'body contains a signature block — the signature did not parse (usually --cleanup=verbatim); recreate the tag with --cleanup=whitespace before it is pushed',
103
+ );
104
+ }
105
+
106
+ const lines = tag.body
107
+ .split('\n')
108
+ .map((line) => line.trim())
109
+ .filter((line) => line.length > 0);
110
+ const headers = lines.filter((line) => SECTION_HEADER.test(line));
111
+ if (headers.length > 0) {
112
+ errors.push(
113
+ `body has section headers (${headers.map((h) => `"${h}"`).join(', ')}) — flat bullets only; sections belong in the changelog entry`,
114
+ );
115
+ }
116
+
117
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
118
+ const changelogLink = new RegExp(
119
+ `^\\[CHANGELOG v${escaped}\\]\\(\\S+/changelog/\\d+\\.\\d+\\.x/${escaped}\\.md\\)`,
120
+ );
121
+ if (!changelogLink.test(lines.at(-1) ?? '')) {
122
+ errors.push(
123
+ `final line is not the changelog link — end the body with "[CHANGELOG v${version}](https://github.com/<OWNER>/<REPO>/blob/main/changelog/<major.minor>.x/${version}.md)"`,
124
+ );
125
+ }
126
+
127
+ return errors;
128
+ }
38
129
 
39
130
  // ── Helpers ───────────────────────────────────────────────────────────────────
40
131
 
@@ -107,15 +198,34 @@ function main(): void {
107
198
  if (!subject) {
108
199
  console.error(
109
200
  `Tag ${tag} not found locally or has no subject line. ` +
110
- `Create the annotated tag first: git tag -a ${tag} -m "..."`,
201
+ `Create the annotated tag first: git tag -a ${tag} -F <file>`,
111
202
  );
112
203
  process.exit(1);
113
204
  }
114
205
 
206
+ // 3. Validate the annotation — it becomes the public release body verbatim
207
+ const errors = checkTagMessage(
208
+ {
209
+ subject,
210
+ body: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(contents:body)']),
211
+ objectType: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(objecttype)']),
212
+ },
213
+ version,
214
+ );
215
+ if (errors.length > 0) {
216
+ console.error(`Tag ${tag} is not publishable:`);
217
+ for (const error of errors) console.error(` ✗ ${error}`);
218
+ process.exit(1);
219
+ }
220
+ if (CHECK_ONLY) {
221
+ console.log(`Tag ${tag} OK.`);
222
+ return;
223
+ }
224
+
115
225
  const title = `${tag}: ${subject}`;
116
226
  const hasMcpb = existsSync('manifest.json');
117
227
 
118
- // 3. Build the gh release create command
228
+ // 4. Build the gh release create command
119
229
  const createArgs = [
120
230
  'release',
121
231
  'create',
@@ -152,7 +262,7 @@ function main(): void {
152
262
  console.log(' asset: dist/*.mcpb');
153
263
  }
154
264
 
155
- // 4. Try to create the release
265
+ // 5. Try to create the release
156
266
  const createResult = gh(createArgs, { required: false });
157
267
 
158
268
  if (!createResult.startsWith('__ERROR__:')) {
@@ -170,7 +280,7 @@ function main(): void {
170
280
  process.exit(1);
171
281
  }
172
282
 
173
- // 5. Release already exists — repair: upload asset (if applicable) and set title.
283
+ // 6. Release already exists — repair: upload asset (if applicable) and set title.
174
284
  console.log(`Release ${tag} already exists. Repairing…`);
175
285
 
176
286
  if (hasMcpb) {
@@ -184,4 +294,6 @@ function main(): void {
184
294
  console.log(`Release ${tag} repaired.`);
185
295
  }
186
296
 
187
- main();
297
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
298
+ main();
299
+ }
@@ -9,6 +9,8 @@
9
9
  # ── Auth ──────────────────────────────────────────────────────────────
10
10
  # MCP_AUTH_MODE=none # none | jwt | oauth (default: none)
11
11
  # MCP_AUTH_SECRET_KEY= # JWT secret (required for jwt mode)
12
+ # MCP_REQUEST_STATE_KEY= # Opt-in, >= 32 bytes, same on every instance: seals the requestState
13
+ # handlers return; any other state is rejected before the handler runs
12
14
 
13
15
  # ── Storage ───────────────────────────────────────────────────────────
14
16
  # STORAGE_PROVIDER_TYPE=in-memory # in-memory | filesystem | supabase | cloudflare-r2 | cloudflare-kv | cloudflare-d1
@@ -201,11 +201,12 @@ Handlers receive a unified `ctx` object. Key properties:
201
201
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
202
202
  | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
203
203
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
204
- | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
204
+ | `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). |
205
+ | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. |
205
206
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
206
207
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
207
208
  | `ctx.signal` | `AbortSignal` for cancellation. |
208
- | `ctx.requestId` | Unique request ID. |
209
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
209
210
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
210
211
 
211
212
  ---
@@ -214,7 +215,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
215
 
215
216
  Handlers throw — the framework catches, classifies, and formats.
216
217
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request <id>)`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
219
 
219
220
  ```ts
220
221
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -226,7 +227,7 @@ errors: [
226
227
  ],
227
228
  async handler(input, ctx) {
228
229
  const item = await db.find(input.id);
229
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
230
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
230
231
  return item;
231
232
  }
232
233
  ```
@@ -201,11 +201,12 @@ Handlers receive a unified `ctx` object. Key properties:
201
201
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
202
202
  | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
203
203
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
204
- | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
204
+ | `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). |
205
+ | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. |
205
206
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
206
207
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
207
208
  | `ctx.signal` | `AbortSignal` for cancellation. |
208
- | `ctx.requestId` | Unique request ID. |
209
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
209
210
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
210
211
 
211
212
  ---
@@ -214,7 +215,7 @@ Handlers receive a unified `ctx` object. Key properties:
214
215
 
215
216
  Handlers throw — the framework catches, classifies, and formats.
216
217
 
217
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request <id>)`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
218
219
 
219
220
  ```ts
220
221
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -226,7 +227,7 @@ errors: [
226
227
  ],
227
228
  async handler(input, ctx) {
228
229
  const item = await db.find(input.id);
229
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
230
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
230
231
  return item;
231
232
  }
232
233
  ```
@@ -5,10 +5,10 @@
5
5
  # source code into JavaScript, and prepares the production assets.
6
6
  #
7
7
  # Pinned to $BUILDPLATFORM rather than the target platform: `bun run build` emits
8
- # JavaScript, and only `dist/` crosses into the production stage, which runs its
9
- # own target-arch install. Built for the target instead, the non-native leg of a
10
- # `--platform linux/amd64,linux/arm64` build runs under QEMU, where bun >= 1.4
11
- # aborts with a JavaScriptCore allocator assertion and fails the multi-arch push.
8
+ # JavaScript, and only `dist/` crosses into the production stage. Built for the
9
+ # target instead, the non-native leg of a `--platform linux/amd64,linux/arm64`
10
+ # build runs under QEMU, where bun >= 1.4 aborts with a JavaScriptCore allocator
11
+ # assertion and fails the multi-arch push.
12
12
  #
13
13
  # The constraint this assumes: the build stage produces platform-independent
14
14
  # output. A stage that compiles a native addon needs the target-arch toolchain
@@ -34,80 +34,97 @@ RUN bun run build
34
34
 
35
35
 
36
36
  # ==============================================================================
37
- # Production Stage
37
+ # Production Dependencies Stage
38
38
  #
39
- # This stage creates a minimal, optimized, and secure image for running the
40
- # application. It uses a slim base image and only includes production
41
- # dependencies and build artifacts.
39
+ # Installs the production dependency tree for the target platform. Every step
40
+ # here can run JavaScript — bunfig.toml's security scanner runs as a Bun
41
+ # program, and so does the OTel script — so the stage runs on $BUILDPLATFORM
42
+ # and cross-installs with `--os`/`--cpu`, which pick each platform-specific
43
+ # optional dependency (native bindings such as DuckDB's) for the target. Only
44
+ # `node_modules` leaves this stage.
45
+ #
46
+ # A clean image rather than `FROM build`: the build stage's node_modules holds
47
+ # devDependencies.
42
48
  # ==============================================================================
43
- FROM oven/bun:1.4.2-slim AS production
49
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS deps
44
50
 
45
51
  WORKDIR /usr/src/app
46
52
 
47
- # Set the environment to production for performance and to ensure only
48
- # production dependencies are installed.
49
- ENV NODE_ENV=production
50
-
51
- # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
52
- ARG APP_VERSION
53
- LABEL org.opencontainers.image.title="{{PACKAGE_NAME}}"
54
- LABEL org.opencontainers.image.description=""
55
- LABEL org.opencontainers.image.licenses="Apache-2.0"
56
- LABEL org.opencontainers.image.version="${APP_VERSION}"
57
- LABEL org.opencontainers.image.source=""
58
-
59
53
  # Copy dependency manifests. `bunfig.toml` rides along so every install below
60
54
  # passes its release-age gate and security scanner, as a local install does.
61
55
  COPY package.json bun.lock bunfig.toml ./
62
56
 
63
57
  # The scanner bunfig.toml names is a devDependency, and Bun installs a missing
64
58
  # scanner through the same production-filtered install, which omits it and
65
- # aborts. Seed it from the build stage's full install instead. Remove this line
66
- # together with the scanner if bunfig.toml stops naming one.
59
+ # aborts. Seed it from the build stage's full install instead. Remove this line,
60
+ # and the `rm` at the end of this stage, if bunfig.toml stops naming a scanner.
67
61
  COPY --from=build /usr/src/app/node_modules/@socketsecurity/bun-security-scanner ./node_modules/@socketsecurity/bun-security-scanner
68
62
 
63
+ # Docker names the target architecture `amd64`/`arm64`; Bun's `--cpu` takes
64
+ # `x64`/`arm64`. Mapped once here, read by both installs below. `oven/bun`
65
+ # publishes only these two architectures, so any other target fails here.
66
+ ARG TARGETOS
67
+ ARG TARGETARCH
68
+ RUN case "$TARGETARCH" in \
69
+ amd64) echo x64 ;; \
70
+ arm64) echo arm64 ;; \
71
+ *) echo "Unsupported TARGETARCH '$TARGETARCH': expected amd64 or arm64" >&2; exit 1 ;; \
72
+ esac > .bun-cpu
73
+
69
74
  # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
70
75
  # that are not needed in the final production image.
71
76
  # `--omit=peer` drops the framework's optional peer tiers (test runner, service
72
77
  # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
73
78
  # actually imports belongs in its own `dependencies`, so nothing needed at
74
- # runtime is lost. The OTEL step below carries the same flag — without it, that
75
- # install re-resolves the graph and pulls every optional peer back in.
79
+ # runtime is lost.
76
80
  RUN --mount=type=cache,target=/root/.bun/install/cache \
77
- bun install --production --omit=peer --frozen-lockfile --ignore-scripts
81
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts \
82
+ --os="$TARGETOS" --cpu="$(cat .bun-cpu)"
78
83
 
79
84
  # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
80
85
  # Installed by default. Omit them for a leaner image at build time
81
86
  # with: docker build --build-arg OTEL_ENABLED=false
82
- # Each package is requested at the range the installed framework declares in
83
- # `peerDependencies`, so the resolution stays inside the framework's tested
84
- # peer range; a name with no declared range fails the build.
87
+ # The script reads the list and each range from the installed framework's
88
+ # `peerDependencies` and passes the target flags on to its `bun install`.
89
+ COPY scripts/install-otel.ts ./scripts/
85
90
  ARG OTEL_ENABLED=true
86
91
  RUN --mount=type=cache,target=/root/.bun/install/cache \
87
92
  if [ "$OTEL_ENABLED" = "true" ]; then \
88
- specs=$(bun -e ' \
89
- const { peerDependencies: peers } = await Bun.file("node_modules/@cyanheads/mcp-ts-core/package.json").json(); \
90
- const names = process.argv.slice(1); \
91
- const missing = names.filter((name) => !peers?.[name]); \
92
- if (missing.length > 0) throw new Error(`no peerDependencies range for ${missing.join(", ")}`); \
93
- console.log(names.map((name) => `${name}@${peers[name]}`).join(" ")); \
94
- ' \
95
- @hono/otel \
96
- @opentelemetry/api-logs \
97
- @opentelemetry/exporter-logs-otlp-http \
98
- @opentelemetry/exporter-metrics-otlp-http \
99
- @opentelemetry/exporter-trace-otlp-http \
100
- @opentelemetry/instrumentation-http \
101
- @opentelemetry/instrumentation-pino \
102
- @opentelemetry/resources \
103
- @opentelemetry/sdk-logs \
104
- @opentelemetry/sdk-metrics \
105
- @opentelemetry/sdk-node \
106
- @opentelemetry/sdk-trace-node \
107
- @opentelemetry/semantic-conventions) \
108
- && bun add --omit=dev --omit=peer --ignore-scripts $specs; \
93
+ bun scripts/install-otel.ts --os="$TARGETOS" --cpu="$(cat .bun-cpu)"; \
109
94
  fi
110
95
 
96
+ # The seeded scanner served only the installs above; keep it out of the image.
97
+ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
98
+
99
+
100
+ # ==============================================================================
101
+ # Production Stage
102
+ #
103
+ # This stage creates a minimal, optimized, and secure image for running the
104
+ # application. It uses a slim base image and only includes production
105
+ # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
106
+ # and CMD, which run on the real target at container start.
107
+ # ==============================================================================
108
+ FROM oven/bun:1.4.2-slim AS production
109
+
110
+ WORKDIR /usr/src/app
111
+
112
+ # Set the environment to production for performance.
113
+ ENV NODE_ENV=production
114
+
115
+ # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
116
+ ARG APP_VERSION
117
+ LABEL org.opencontainers.image.title="{{PACKAGE_NAME}}"
118
+ LABEL org.opencontainers.image.description=""
119
+ LABEL org.opencontainers.image.licenses="Apache-2.0"
120
+ LABEL org.opencontainers.image.version="${APP_VERSION}"
121
+ LABEL org.opencontainers.image.source=""
122
+
123
+ # The manifest comes from the build context: the deps stage's copy was rewritten
124
+ # by the OTel install, and the runtime reads only its name, version, and type.
125
+ COPY package.json ./
126
+ COPY --from=deps /usr/src/app/node_modules ./node_modules
127
+
111
128
  # Copy the compiled application code from the build stage
112
129
  COPY --from=build /usr/src/app/dist ./dist
113
130
 
@@ -3,6 +3,8 @@
3
3
  /.claude/
4
4
  /.agents/
5
5
  /framework-skills/
6
+ /docs/idea.md
7
+ /logs/
6
8
  /Dockerfile
7
9
  /bun.lock
8
10
  /bunfig.toml
@@ -27,7 +27,8 @@ export const echoTool = tool('template_echo_message', {
27
27
  },
28
28
 
29
29
  // Declare each domain failure mode the agent should plan around. The framework
30
- // types `ctx.fail(reason, …)` against the declared union. Baseline codes
30
+ // types `ctx.fail(reason, …)` against the declared union and puts the entry's
31
+ // `recovery` on both client surfaces when the throw carries none. Baseline codes
31
32
  // (InternalError, ServiceUnavailable, Timeout, ValidationError,
32
33
  // SerializationError) bubble freely — only declare domain-specific reasons.
33
34
  // Delete this block if no domain-specific failures apply to your tool.
@@ -42,13 +43,7 @@ export const echoTool = tool('template_echo_message', {
42
43
 
43
44
  handler(input, ctx) {
44
45
  if (input.message.trim().length === 0) {
45
- // Spreading ctx.recoveryFor puts the declared recovery on the wire. Without
46
- // it the hint reaches neither structuredContent nor content[].
47
- throw ctx.fail(
48
- 'empty_message',
49
- 'Message must contain at least one non-whitespace character.',
50
- { ...ctx.recoveryFor('empty_message') },
51
- );
46
+ throw ctx.fail('empty_message', 'Message must contain at least one non-whitespace character.');
52
47
  }
53
48
  // Reaches both client surfaces with no format() plumbing.
54
49
  ctx.enrich({ characterCount: input.message.length });