@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
@@ -5,9 +5,9 @@
5
5
  */
6
6
  import { ZodError, z } from 'zod';
7
7
  import { config } from '../../../config/index.js';
8
- import { readContentStore, readEnrichmentStore } from '../../../core/context.js';
8
+ import { readContentStore, readEnrichmentStore, resolveDeclaredFailure } from '../../../core/context.js';
9
9
  import { buildHandlerContext, handlerParentContext, resolveHandlerRequest, } from '../../handlerContext.js';
10
- import { isInputRequiredSignal } from '../../inputRequired.js';
10
+ import { CLIENT_CAPABILITY_MISSING_REASON, isInputRequiredSignal, sealThrown, } from '../../inputRequired.js';
11
11
  import { parseOutputContract } from '../../outputContract.js';
12
12
  import { withRequiredScopes } from '../../transports/auth/lib/authUtils.js';
13
13
  import { internalError, JsonRpcErrorCode, McpError, } from '../../../types-global/errors.js';
@@ -17,8 +17,8 @@ import { measureToolExecution, recordToolRejection } from '../../../utils/intern
17
17
  import { requestContextService, withExtra, } from '../../../utils/internal/requestContext.js';
18
18
  import { sanitization } from '../../../utils/security/sanitization.js';
19
19
  import { ATTR_MCP_TOOL_ENRICHED } from '../../../utils/telemetry/attributes.js';
20
- import { countCoerced, prevalidateToolArguments, repairRepresentations, } from './inputPrevalidation.js';
21
- import { isZodObjectSchema } from './schemaShape.js';
20
+ import { countCoerced, prevalidateAliasFirst, prevalidateToolArguments, recordPrevalidation, repairRepresentations, } from './inputPrevalidation.js';
21
+ import { isZodObjectSchema, zodDef } from './schemaShape.js';
22
22
  // ---------------------------------------------------------------------------
23
23
  // Default formatter
24
24
  // ---------------------------------------------------------------------------
@@ -62,15 +62,17 @@ function extractRecoveryHint(data) {
62
62
  /**
63
63
  * The compact trailing line carrying the two `data` fields a caller branches
64
64
  * on — `reason`, the stable identifier, and `retryable`, whether a retry can
65
- * succeed (#458). Both reach `structuredContent.error.data`; without this line
66
- * neither reaches the text surface, so a model that just failed cannot tell a
67
- * deterministic rejection from a transient one.
65
+ * succeed (#458) — then `request <id>`, the `data.requestId` the server's log
66
+ * records carry (#576). All three reach `structuredContent.error.data`;
67
+ * without this line none reaches the text surface, so a model that just failed
68
+ * cannot tell a deterministic rejection from a transient one, and a failure
69
+ * reported from a `content[]`-only client cannot be matched to its log record.
68
70
  *
69
- * Returns `undefined` when `data` carries neither — a classified plain
70
- * `Error`, an `McpError` with no `data` — leaving the text as it was. The
71
- * numeric `code` and `data.issues` stay JSON-only on purpose: the code is the
72
- * one envelope field a model cannot act on, and the message already renders
73
- * each issue as a sentence.
71
+ * Returns `undefined` when `data` carries none of them — an `McpError` with no
72
+ * `data` outside a request, as `runToolContract` builds it — leaving the text
73
+ * as it was. The numeric `code` and `data.issues` stay JSON-only on purpose:
74
+ * the code is the one envelope field a model cannot act on, and the message
75
+ * already renders each issue as a sentence.
74
76
  */
75
77
  function renderBranchableTerms(data) {
76
78
  const terms = [];
@@ -80,6 +82,9 @@ function renderBranchableTerms(data) {
80
82
  if (typeof data?.retryable === 'boolean') {
81
83
  terms.push(data.retryable ? 'retryable' : 'not retryable');
82
84
  }
85
+ if (typeof data?.requestId === 'string' && data.requestId.length > 0) {
86
+ terms.push(`request ${data.requestId}`);
87
+ }
83
88
  return terms.length > 0 ? `(${terms.join(' · ')})` : undefined;
84
89
  }
85
90
  /**
@@ -89,17 +94,18 @@ function renderBranchableTerms(data) {
89
94
  * - `structuredContent.error` — read by clients like Claude Code (JSON)
90
95
  *
91
96
  * The text carries the message, the `data.recovery.hint` when it adds
92
- * something, and the branchable `reason` / `retryable` terms
93
- * {@link renderBranchableTerms} renders; the numeric `code` and `data.issues`
94
- * stay JSON-only.
97
+ * something, and the `reason` / `retryable` / `request` terms
98
+ * {@link renderBranchableTerms} renders, in that order; the numeric `code` and
99
+ * `data.issues` stay JSON-only.
95
100
  *
96
101
  * The `Recovery:` line is dropped when the message already contains the hint
97
- * verbatim (#459) — `buildArgumentRecoveryHint` falls back to an issue's own
98
- * message for a constraint or refinement, and repeating that sentence costs
99
- * the reader without adding a next step. Containment, not equality: the
100
- * argument-rejection preamble and a field-path prefix both leave the hint's
101
- * whole text on screen. `structuredContent.error.data.recovery.hint` stays
102
- * populated either way, so #445's guarantee holds on the JSON surface.
102
+ * verbatim (#459) — `buildArgumentRecoveryHint` restates a constraint or
103
+ * refinement issue as its own message line, and a hint made only of those is
104
+ * the message's issue text, so repeating it costs the reader without adding a
105
+ * next step. Containment, not equality: the argument-rejection preamble leaves
106
+ * that text whole on screen, and so does a handler hint the message embeds.
107
+ * `structuredContent.error.data.recovery.hint` stays populated either way, so
108
+ * #445's guarantee holds on the JSON surface.
103
109
  *
104
110
  * Note: `_meta.error` is intentionally NOT emitted — the error code, message,
105
111
  * and data live on `structuredContent.error` instead, mirroring the success
@@ -146,36 +152,91 @@ const ABSENT = Symbol('absent');
146
152
  * is how Zod itself reads it.
147
153
  */
148
154
  function readArgumentAt(args, path) {
149
- let cursor = args;
150
- for (const segment of path) {
151
- if (cursor === null || typeof cursor !== 'object')
152
- return ABSENT;
153
- if (!Object.hasOwn(cursor, segment))
154
- return ABSENT;
155
- cursor = cursor[segment];
156
- }
157
- return cursor === undefined ? ABSENT : cursor;
155
+ const value = path.reduce(stepInto, args);
156
+ return value === undefined ? ABSENT : value;
157
+ }
158
+ /** The caller's value one step down, or `undefined` when nothing owns one there. */
159
+ function stepInto(value, step) {
160
+ return value !== null && typeof value === 'object' && Object.hasOwn(value, step)
161
+ ? value[step]
162
+ : undefined;
158
163
  }
159
164
  /** The accepted-value half of an `invalid_value` sentence, from the issue's own values. */
160
165
  function expectedValuesText(values) {
161
166
  const rendered = values.map((value) => JSON.stringify(value)).join('|');
162
167
  return values.length === 1 ? `Expected ${rendered}` : `Expected one of ${rendered}`;
163
168
  }
169
+ /** The branch's only issue, when it has exactly one. */
170
+ function onlyIssue(branch) {
171
+ return branch.length === 1 ? branch[0] : undefined;
172
+ }
173
+ /** Whether any of a branch's issues names a path below the branch's root. */
174
+ function failsBelowRoot(branch) {
175
+ return branch.some((issue) => issue.path.length > 0);
176
+ }
164
177
  /**
165
- * The union branches worth rendering: every branch except one whose only issue
166
- * is a single-valued `invalid_value`.
178
+ * The union branches worth rendering. Two filters, both reading issue shape
179
+ * only, never message text:
167
180
  *
168
- * That shape is the `z.literal('')` blank-field sentinel of the form-client
169
- * convention — never the branch that says what would have been accepted. The
170
- * filter reads issue shape only, never message text, so a one-entry
171
- * `z.enum([...])` (which Zod reports identically) is filtered too and the
172
- * caller falls back to the union's own message.
181
+ * - **#417** — drop a branch whose only issue is a single-valued
182
+ * `invalid_value`. That shape is the `z.literal('')` blank-field sentinel of
183
+ * the form-client convention — never the branch that says what would have
184
+ * been accepted. A one-entry `z.enum([...])`, which Zod reports identically,
185
+ * is filtered too, and the caller falls back to the union's own message.
186
+ * - **#492** — once some branch fails below its root, drop every branch whose
187
+ * only issue is a root `invalid_type`. In a one-or-many field
188
+ * (`z.union([z.array(Item), Item])`) that branch merely says the value is
189
+ * the other shape; the branch that failed inside the value is the one that
190
+ * says what to change. When every branch fails at its root, none is dropped.
173
191
  */
174
192
  function selectUnionBranches(branches) {
175
- return branches.filter((branch) => {
176
- const only = branch.length === 1 ? branch[0] : undefined;
193
+ const selected = branches.filter((branch) => {
194
+ const only = onlyIssue(branch);
177
195
  return !(only?.code === 'invalid_value' && only.values.length === 1);
178
196
  });
197
+ if (!selected.some(failsBelowRoot))
198
+ return selected;
199
+ return selected.filter((branch) => {
200
+ const only = onlyIssue(branch);
201
+ return !(only?.code === 'invalid_type' && only.path.length === 0);
202
+ });
203
+ }
204
+ /**
205
+ * The issues a rejection renders, in order: Zod's list, except that a union
206
+ * left with one selected branch that fails below its root is replaced by that
207
+ * branch's issues under the union's path (#492) — so a one-or-many field
208
+ * reports a list element's field error exactly as a list-only field does
209
+ * (`items.1.name: …`). Recursive, so a one-or-many union nested in another, or
210
+ * inside a list element, resolves the same way at every level.
211
+ *
212
+ * The message ({@link formatInputValidationMessage}) and the hint
213
+ * ({@link buildArgumentRecoveryHint}) both render from this list, which keeps
214
+ * the hint's restatements identical to the message's lines. `data.issues` is
215
+ * never rebuilt from it: it ships Zod's own list.
216
+ */
217
+ function renderedIssues(issues, prefix = []) {
218
+ return issues.flatMap((issue) => {
219
+ const path = [...prefix, ...issue.path];
220
+ if (issue.code === 'invalid_union') {
221
+ const [branch, ...others] = selectUnionBranches(issue.errors);
222
+ if (branch && others.length === 0 && failsBelowRoot(branch)) {
223
+ return renderedIssues(branch, path);
224
+ }
225
+ }
226
+ return [{ issue, path }];
227
+ });
228
+ }
229
+ /** `items.1.name` — the dotted form a path takes in the message and the hint. */
230
+ function dottedPath(path) {
231
+ return path.map(String).join('.');
232
+ }
233
+ /**
234
+ * One line of the rendered detail: `path: message`, or the bare message at the
235
+ * root. `args` decides the absent/present bit {@link renderIssueMessage} reads.
236
+ */
237
+ function renderIssueLine({ issue, path }, args) {
238
+ const message = renderIssueMessage(issue, readArgumentAt(args, path) === ABSENT);
239
+ return path.length > 0 ? `${dottedPath(path)}: ${message}` : message;
179
240
  }
180
241
  /**
181
242
  * The readable half of one issue's rendered line.
@@ -228,7 +289,7 @@ function renderIssueMessage(issue, absent) {
228
289
  */
229
290
  function renderBranchIssue(issue, absent) {
230
291
  const message = renderIssueMessage(issue, absent);
231
- return issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${message}` : message;
292
+ return issue.path.length > 0 ? `${dottedPath(issue.path)}: ${message}` : message;
232
293
  }
233
294
  /**
234
295
  * Renders an argument-validation failure the way the MCP SDK renders its own,
@@ -241,11 +302,8 @@ function renderBranchIssue(issue, absent) {
241
302
  * {@link readArgumentAt}; see {@link renderIssueMessage} for what that decides.
242
303
  */
243
304
  export function formatInputValidationMessage(toolName, error, args) {
244
- const detail = error.issues
245
- .map((issue) => {
246
- const message = renderIssueMessage(issue, readArgumentAt(args, issue.path) === ABSENT);
247
- return issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${message}` : message;
248
- })
305
+ const detail = renderedIssues(error.issues)
306
+ .map((entry) => renderIssueLine(entry, args))
249
307
  .join(', ');
250
308
  return `Input validation error: Invalid arguments for tool ${toolName}: ${detail}`;
251
309
  }
@@ -277,30 +335,145 @@ function joinNames(names) {
277
335
  function rootPropertyNames(input) {
278
336
  return isZodObjectSchema(input) ? Object.keys(input.shape) : [];
279
337
  }
338
+ /**
339
+ * The `z.object()` a nested unknown-key issue sits in, found by walking its
340
+ * rendered path down from `schema` beside the caller's own `value` — or
341
+ * `undefined` when the path does not land on exactly one object (#566).
342
+ *
343
+ * Wrappers (`optional`, `nullable`, `default`, …), `pipe`, and `z.lazy()` are
344
+ * looked through. A numeric step enters an array element or tuple item; a
345
+ * string step, a property or a record value. A discriminated union follows the
346
+ * variant the argument's own discriminator selects, as Zod did. A plain union
347
+ * follows the one option under which the rest of the path still lands on an
348
+ * object — the branch {@link renderedIssues} lifted under #492. Anything else,
349
+ * an intersection or a union two options satisfy, resolves to nothing.
350
+ */
351
+ function objectSchemaAt(schema, path, value) {
352
+ const def = zodDef(schema);
353
+ if (!def)
354
+ return undefined;
355
+ if (def.type === 'lazy' && def.getter)
356
+ return objectSchemaAt(def.getter(), path, value);
357
+ if (def.type === 'pipe')
358
+ return objectSchemaAt(def.in, path, value);
359
+ if (def.innerType !== undefined)
360
+ return objectSchemaAt(def.innerType, path, value);
361
+ if (def.type === 'union')
362
+ return unionOptionAt(def, path, value);
363
+ const [step, ...rest] = path;
364
+ if (step === undefined)
365
+ return isZodObjectSchema(schema) ? schema : undefined;
366
+ const next = stepInto(value, step);
367
+ switch (def.type) {
368
+ case 'object':
369
+ return typeof step === 'string' && def.shape && Object.hasOwn(def.shape, step)
370
+ ? objectSchemaAt(def.shape[step], rest, next)
371
+ : undefined;
372
+ case 'record':
373
+ return objectSchemaAt(def.valueType, rest, next);
374
+ case 'array':
375
+ return typeof step === 'number' ? objectSchemaAt(def.element, rest, next) : undefined;
376
+ case 'tuple':
377
+ return typeof step === 'number'
378
+ ? objectSchemaAt(def.items?.[step] ?? def.rest, rest, next)
379
+ : undefined;
380
+ default:
381
+ return undefined;
382
+ }
383
+ }
384
+ /** {@link objectSchemaAt} at a union: the one option the path resolves through. */
385
+ function unionOptionAt(def, path, value) {
386
+ const options = def.options ?? [];
387
+ const { discriminator } = def;
388
+ if (typeof discriminator === 'string') {
389
+ const tag = stepInto(value, discriminator);
390
+ const selected = options.find((option) => {
391
+ const field = isZodObjectSchema(option) ? option.shape[discriminator] : undefined;
392
+ return field?.safeParse(tag).success === true;
393
+ });
394
+ return selected === undefined ? undefined : objectSchemaAt(selected, path, value);
395
+ }
396
+ const resolved = options.flatMap((option) => objectSchemaAt(option, path, value) ?? []);
397
+ return resolved.length === 1 ? resolved[0] : undefined;
398
+ }
399
+ /**
400
+ * The unknown-key sentence for one `unrecognized_keys` issue.
401
+ *
402
+ * A root key names the root properties the tool advertises (#445). A key inside
403
+ * a nested strict object is named by its full path, beside the keys that object
404
+ * accepts (#566) — the root list there would send the caller to move the key to
405
+ * the root or rename it after a root field. Neither list is given when there is
406
+ * none to give: a discriminated-union root, a nested object declaring no keys,
407
+ * or a path {@link objectSchemaAt} cannot resolve.
408
+ */
409
+ function unknownKeySentence(input, keys, path, args) {
410
+ const label = keys.length === 1 ? 'Unknown key' : 'Unknown keys';
411
+ if (path.length === 0) {
412
+ const accepted = rootPropertyNames(input);
413
+ return accepted.length > 0
414
+ ? `${label} ${keys.join(', ')}. This tool accepts: ${accepted.join(', ')}.`
415
+ : `${label} ${keys.join(', ')}.`;
416
+ }
417
+ const where = dottedPath(path);
418
+ const named = keys.map((key) => `${where}.${key}`).join(', ');
419
+ const accepted = Object.keys(objectSchemaAt(input, path, args)?.shape ?? {});
420
+ return accepted.length > 0
421
+ ? `${label} ${named}. ${where} accepts: ${accepted.join(', ')}.`
422
+ : `${label} ${named}.`;
423
+ }
424
+ /**
425
+ * The wrong-type sentence for one `invalid_type` issue.
426
+ *
427
+ * `int` is the one expectation a JSON number fails by type — `.int()`,
428
+ * `z.int()`, `z.int32()`, and `z.uint32()` all report it, while range and
429
+ * safe-integer violations arrive as `too_big` / `too_small` — so a number
430
+ * arriving there has a fractional part, and the sentence names that fix
431
+ * rather than the type names `int` and `number`, which the value already
432
+ * satisfies (#499).
433
+ */
434
+ function wrongTypeSentence(subject, expected, arrived) {
435
+ if (arrived === ABSENT)
436
+ return `Send ${subject} as ${withArticle(expected)}.`;
437
+ if (expected === 'int' && typeof arrived === 'number') {
438
+ return `Send ${subject} as an integer, not a fractional number.`;
439
+ }
440
+ return `Send ${subject} as ${withArticle(expected)}, not ${arrivedTypeText(arrived)}.`;
441
+ }
280
442
  /**
281
443
  * Synthesizes `data.recovery.hint` from the Zod issues, the raw arguments, and
282
444
  * the root schema (#445) — so the one failure a weaker model hits most often
283
445
  * carries the same next step every handler-thrown error does, instead of
284
446
  * costing a round trip for the schema.
285
447
  *
286
- * One sentence per issue, joined into a single hint, except that every missing
287
- * required field collapses into one `Provide …` sentence at the first of their
288
- * positions. An issue no bucket claims contributes its rendered message
289
- * unchanged, which keeps the hint nonempty for any rejection Zod can produce.
448
+ * One sentence per {@link renderedIssues} entry, joined into a single hint,
449
+ * except that every missing required field collapses into one `Provide …`
450
+ * sentence at the first of their positions. An issue no bucket claims is
451
+ * restated as its message line — `start: Must be …`, path included, so
452
+ * identical constraints on different fields stay distinguishable (#493).
453
+ *
454
+ * When every sentence is a restatement, the hint is the message's issue text
455
+ * verbatim, which is what lets {@link buildToolErrorResult} drop the
456
+ * `Recovery:` line (#459). When restatements share the hint with the
457
+ * framework's own sentences, each is terminated so it cannot run into the next
458
+ * one, and a sentence already stated is not repeated.
459
+ *
460
+ * `report` closes the hint with what the pre-validation step changed before
461
+ * the parse (#468) — `Validated query as targetQuery.` for a rewritten key,
462
+ * `Dropped undeclared key _max.` for an underscore-rule drop — since the issues
463
+ * name only the keys that were validated. Both are framework sentences, never
464
+ * restatements, so a hint carrying one keeps its `Recovery:` line.
290
465
  */
291
- function buildArgumentRecoveryHint(def, error, args) {
466
+ function buildArgumentRecoveryHint(def, error, args, report) {
292
467
  const sentences = [];
468
+ const restatements = [];
293
469
  const missing = [];
294
470
  let missingSlot = -1;
295
- for (const issue of error.issues) {
296
- const path = issue.path.map(String).join('.');
297
- const arrived = readArgumentAt(args, issue.path);
471
+ for (const entry of renderedIssues(error.issues)) {
472
+ const { issue } = entry;
473
+ const path = dottedPath(entry.path);
474
+ const arrived = readArgumentAt(args, entry.path);
298
475
  if (issue.code === 'unrecognized_keys') {
299
- const label = issue.keys.length === 1 ? 'Unknown key' : 'Unknown keys';
300
- const accepted = rootPropertyNames(def.input);
301
- sentences.push(accepted.length > 0
302
- ? `${label} ${issue.keys.join(', ')}. This tool accepts: ${accepted.join(', ')}.`
303
- : `${label} ${issue.keys.join(', ')}.`);
476
+ sentences.push(unknownKeySentence(def.input, issue.keys, entry.path, args));
304
477
  continue;
305
478
  }
306
479
  if (path.length > 0 && arrived === ABSENT) {
@@ -312,18 +485,26 @@ function buildArgumentRecoveryHint(def, error, args) {
312
485
  continue;
313
486
  }
314
487
  if (issue.code === 'invalid_type') {
315
- const subject = path.length > 0 ? path : 'the arguments';
316
- const expected = withArticle(issue.expected);
317
- sentences.push(arrived === ABSENT
318
- ? `Send ${subject} as ${expected}.`
319
- : `Send ${subject} as ${expected}, not ${arrivedTypeText(arrived)}.`);
488
+ sentences.push(wrongTypeSentence(path.length > 0 ? path : 'the arguments', issue.expected, arrived));
320
489
  continue;
321
490
  }
322
- sentences.push(renderIssueMessage(issue, false));
491
+ const line = renderIssueLine(entry, args);
492
+ restatements.push(line);
493
+ sentences.push(terminateSentence(line));
494
+ }
495
+ if (report && report.aliased.length > 0) {
496
+ const rewrites = report.aliased.map(({ alias, target }) => `${alias} as ${target}`);
497
+ sentences.push(`Validated ${joinNames(rewrites)}.`);
323
498
  }
499
+ if (report && report.ignored.length > 0) {
500
+ const label = report.ignored.length === 1 ? 'key' : 'keys';
501
+ sentences.push(`Dropped undeclared ${label} ${joinNames(report.ignored)}.`);
502
+ }
503
+ if (restatements.length === sentences.length)
504
+ return restatements.join(', ');
324
505
  if (missingSlot >= 0)
325
506
  sentences[missingSlot] = `Provide ${joinNames(missing)}.`;
326
- return sentences.join(' ');
507
+ return [...new Set(sentences)].join(' ');
327
508
  }
328
509
  /**
329
510
  * Validates raw tool arguments against the definition's `input` schema, or
@@ -337,10 +518,22 @@ function buildArgumentRecoveryHint(def, error, args) {
337
518
  * An ordered pre-validation step wraps the parse. Before it,
338
519
  * {@link prevalidateToolArguments} drops client-added keys (#453) and rewrites
339
520
  * key aliases (#452); after a failure — and only then —
340
- * {@link repairRepresentations} undoes a stringified array and the arguments
341
- * are parsed once more (#234), the repair kept only if the author's own schema
342
- * now accepts it. When nothing validates, the *original* rejection is thrown
343
- * verbatim, built from the arguments that produced it.
521
+ * {@link repairRepresentations} undoes a stringified array or object or an
522
+ * integer sent for a string, and the arguments are parsed once more (#234,
523
+ * #479, #487), the repair kept only if the author's own schema now accepts it.
524
+ * When that attempt still fails and its drop discarded a key,
525
+ * {@link prevalidateAliasFirst} reruns the stages alias-first and the same
526
+ * parse-then-repair runs on the result, kept only if it validates (#563).
527
+ * {@link recordPrevalidation} then counts and logs the attempt the handler
528
+ * receives, so a call the first attempt validates is untouched by the retry.
529
+ * When nothing validates, the *original* rejection of the last attempt is
530
+ * thrown — the retry's when it ran, since there every key the drop discarded
531
+ * reached its target and the issues name what is wrong with the value it
532
+ * carried — built from the arguments that produced it, identical to the one
533
+ * the same call gets under `input: { coerce: false }`. It carries the rewrites
534
+ * and underscore-rule drops that attempt made as `data.input`, and as
535
+ * sentences closing the hint (#468); a call with neither gains no
536
+ * `data.input`.
344
537
  *
345
538
  * The single argument-rejection path. {@link createToolHandler} and the
346
539
  * `runToolContract` test helper both route through it, so a test written to
@@ -351,44 +544,88 @@ function buildArgumentRecoveryHint(def, error, args) {
351
544
  * ({@link parseToolOutput}).
352
545
  */
353
546
  export function parseToolArguments(def, input, options = {}) {
354
- const prepared = prevalidateToolArguments(def, input, options.input, options.context);
355
- const parsed = def.input.safeParse(prepared);
547
+ const first = prevalidateToolArguments(def, input, options.input);
548
+ const parsed = parseAttempt(def, first.args, options.input);
356
549
  if (parsed.success)
357
- return parsed.data;
358
- if (options.input?.coerce !== false) {
359
- const repaired = repairRepresentations(prepared, parsed.error.issues);
360
- if (repaired !== prepared) {
361
- const retried = def.input.safeParse(repaired);
362
- if (retried.success) {
363
- countCoerced(def.name, options.context);
364
- return retried.data;
365
- }
366
- }
550
+ return accept(def, first, parsed, options.context);
551
+ let rejected = { attempt: first, error: parsed.error };
552
+ const retry = prevalidateAliasFirst(def, input, first, options.input);
553
+ if (retry) {
554
+ const retried = parseAttempt(def, retry.args, options.input);
555
+ if (retried.success)
556
+ return accept(def, retry, retried, options.context);
557
+ rejected = { attempt: retry, error: retried.error };
367
558
  }
368
- throw new McpError(JsonRpcErrorCode.InvalidParams, formatInputValidationMessage(def.name, parsed.error, prepared), {
369
- issues: parsed.error.issues,
559
+ const { attempt, error } = rejected;
560
+ recordPrevalidation(def, attempt, options.context);
561
+ const { report } = attempt;
562
+ throw new McpError(JsonRpcErrorCode.InvalidParams, formatInputValidationMessage(def.name, error, attempt.args), {
563
+ issues: error.issues,
370
564
  reason: INVALID_ARGUMENTS_REASON,
371
- recovery: { hint: buildArgumentRecoveryHint(def, parsed.error, prepared) },
565
+ ...(report && { input: report }),
566
+ recovery: { hint: buildArgumentRecoveryHint(def, error, attempt.args, report) },
372
567
  });
373
568
  }
569
+ /**
570
+ * Parses one attempt's arguments, and on failure repairs them once and
571
+ * re-parses, keeping the repair only if the schema then accepts it. A failure
572
+ * carries the first parse's error: a discarded repair leaves no trace.
573
+ */
574
+ function parseAttempt(def, args, options) {
575
+ const parsed = def.input.safeParse(args);
576
+ if (parsed.success)
577
+ return { success: true, data: parsed.data, coerced: [] };
578
+ if (options?.coerce !== false) {
579
+ const repair = repairRepresentations(args, parsed.error.issues);
580
+ if (repair.args !== args) {
581
+ const retried = def.input.safeParse(repair.args);
582
+ if (retried.success)
583
+ return { success: true, data: retried.data, coerced: repair.kinds };
584
+ }
585
+ }
586
+ return { success: false, error: parsed.error };
587
+ }
588
+ /** Emits the winning attempt's telemetry and hands its arguments to the handler. */
589
+ function accept(def, attempt, parsed, context) {
590
+ recordPrevalidation(def, attempt, context);
591
+ if (parsed.coerced.length > 0)
592
+ countCoerced(def.name, parsed.coerced, context);
593
+ return parsed.data;
594
+ }
374
595
  /**
375
596
  * Builds an error `CallToolResult` from a raw thrown value. Classifies via
376
597
  * {@link ErrorHandler.classifyOnly} when the value isn't already an
377
598
  * `McpError`. Only propagates data from `McpError` (its declared `data`) or
378
- * `ZodError` (its `issues`) — other thrown values get a `structuredContent.error`
379
- * with `code` and `message` only, so internal classification context never
380
- * leaks to clients.
599
+ * `ZodError` (its `issues`), so internal classification context never leaks
600
+ * to clients.
601
+ *
602
+ * `requestId`, when given, is set as `data.requestId` on the envelope — after
603
+ * the thrown data, so the framework's value replaces a thrown one, as
604
+ * canonical fields win in log records (#550) — and closes the `content[]`
605
+ * terms line (#576). It is the one request-context field that reaches `data`,
606
+ * added here rather than to the thrown `McpError.data`, so the error the
607
+ * handler threw and the log record's `errorData` stay context-free (#548).
608
+ * `runToolContract` passes none: it has no real request, so a thrown
609
+ * `data.requestId` is dropped rather than rendered as one.
381
610
  *
382
611
  * Use after invoking {@link ErrorHandler.handleError} for OTel/logging side
383
612
  * effects — this helper does not log.
384
613
  */
385
- export function classifyAndBuildToolErrorResult(error) {
614
+ export function classifyAndBuildToolErrorResult(error, requestId) {
615
+ const withRequestId = (data) => {
616
+ if (requestId !== undefined)
617
+ return { ...data, requestId };
618
+ if (data?.requestId === undefined)
619
+ return data;
620
+ const { requestId: _thrown, ...rest } = data;
621
+ return Object.keys(rest).length > 0 ? rest : undefined;
622
+ };
386
623
  if (error instanceof McpError) {
387
- return buildToolErrorResult(error.code, error.message, error.data);
624
+ return buildToolErrorResult(error.code, error.message, withRequestId(error.data));
388
625
  }
389
626
  const { code, message } = ErrorHandler.classifyOnly(error);
390
627
  const data = error instanceof ZodError ? { issues: error.issues } : undefined;
391
- return buildToolErrorResult(code, message, data);
628
+ return buildToolErrorResult(code, message, withRequestId(data));
392
629
  }
393
630
  // ---------------------------------------------------------------------------
394
631
  // Success response shaping — enrichment merge + trailer
@@ -422,15 +659,15 @@ export function parseToolOutput(def, value) {
422
659
  });
423
660
  }
424
661
  /**
425
- * A contract entry's `when` text, terminated so the entry that follows it —
426
- * and the description's own trailing sentence — starts a new one (#389).
427
- * Nothing validates a punctuation convention on `when`, and an entry authored
428
- * as a fragment otherwise runs into whatever comes next. Punctuating at the
429
- * join leaves the authored text alone, terminator or not.
662
+ * `text`, trimmed and ending in terminal punctuation, so whatever is joined
663
+ * after it starts a new sentence. Punctuating at the join leaves authored text
664
+ * alone, terminator or not: a contract entry's `when` (#389), which nothing
665
+ * validates a punctuation convention on, and an issue message restated in an
666
+ * argument hint beside the framework's own sentences (#493).
430
667
  */
431
- function terminateWhen(when) {
432
- const text = when.trim();
433
- return /[.?!]$/.test(text) ? text : `${text}.`;
668
+ function terminateSentence(text) {
669
+ const trimmed = text.trim();
670
+ return /[.?!]$/.test(trimmed) ? trimmed : `${trimmed}.`;
434
671
  }
435
672
  /**
436
673
  * The error envelope a tool can put on `structuredContent` when it fails,
@@ -457,7 +694,7 @@ function toolErrorEnvelopeSchema(def) {
457
694
  ? z
458
695
  .string()
459
696
  .describe(`Machine-readable failure mode. Declared by this tool: ${contract
460
- .map((entry) => `\`${entry.reason}\`: ${terminateWhen(entry.when)}`)
697
+ .map((entry) => `\`${entry.reason}\`: ${terminateSentence(entry.when)}`)
461
698
  .join(' ')} Other values are possible when a failure originates below the handler.`)
462
699
  .meta({ examples: reasons })
463
700
  : z.string().describe('Machine-readable failure mode.');
@@ -664,25 +901,37 @@ export function buildToolSuccessResult(def, ctx, domainValidated, domainContent)
664
901
  // Factory
665
902
  // ---------------------------------------------------------------------------
666
903
  /**
667
- * The log level the definition declared for the failure that just unwound, or
668
- * `undefined` to keep `error` (#380).
904
+ * The two reasons the framework raises itself, both a refusal of the call
905
+ * rather than a fault in this server: `invalid_arguments`, only from the schema
906
+ * gate in {@link parseToolArguments}, and `client_capability_missing`, only
907
+ * from `ctx.requestInput`'s capability gate.
908
+ */
909
+ const FRAMEWORK_REFUSAL_REASONS = new Set([
910
+ INVALID_ARGUMENTS_REASON,
911
+ CLIENT_CAPABILITY_MISSING_REASON,
912
+ ]);
913
+ /**
914
+ * The level the `Error in tool:<name>` record is emitted at, or `undefined` to
915
+ * keep `error`.
669
916
  *
670
- * The outer catch is the one place holding both the definition and the thrown
671
- * error, so the reason-to-entry lookup happens here rather than inside
672
- * `ErrorHandler`, which sees neither. Resolution is deliberately narrow: an
673
- * `McpError` whose `data.reason` names a contract entry that declared a
674
- * severity. A plain `Error`, a reason thrown below the handler that the
675
- * contract never declared, and an entry with no severity all fall through to
676
- * today's behavior. A cancellation is settled earlier — `asRequestCancelled`
677
- * replaces the thrown value, so no declared reason reaches this point.
917
+ * A severity the matched `errors[]` entry declares wins (#380). Otherwise one
918
+ * of the framework's own refusals ({@link FRAMEWORK_REFUSAL_REASONS}) logs at
919
+ * `notice` (#567): a caller's arguments failing the tool's schema, or a client
920
+ * connection that cannot serve an input request, is routine traffic, and a
921
+ * schema that wrongly rejects valid calls still shows per tool on
922
+ * `mcp.tool.rejections`. Everything else — a plain `Error`, an undeclared
923
+ * reason, an entry with no severity, an auth refusal, an output-contract
924
+ * failure — keeps `error`. A cancellation takes `handleError`'s own `info`
925
+ * path whatever this returns.
926
+ *
927
+ * Resolved here rather than in `ErrorHandler`, which also serves services,
928
+ * prompts, and transports and knows neither the definition nor the schema gate.
678
929
  */
679
- function declaredSeverity(def, error) {
680
- if (!(error instanceof McpError))
681
- return undefined;
682
- const reason = error.data?.reason;
683
- if (typeof reason !== 'string')
684
- return undefined;
685
- return def.errors?.find((entry) => entry.reason === reason)?.severity;
930
+ function failureSeverity(entry, failure) {
931
+ if (entry?.severity !== undefined)
932
+ return entry.severity;
933
+ const reason = failure instanceof McpError ? failure.data?.reason : undefined;
934
+ return typeof reason === 'string' && FRAMEWORK_REFUSAL_REASONS.has(reason) ? 'notice' : undefined;
686
935
  }
687
936
  /**
688
937
  * Creates an MCP tool handler from a tool definition.
@@ -698,14 +947,14 @@ function declaredSeverity(def, error) {
698
947
  * - Catches errors and returns `isError: true`, counting one raised before the
699
948
  * measured region (the scope check, argument validation) on `mcp.tool.rejections`
700
949
  */
701
- export function createToolHandler(def, services, notifiers, inputGate) {
950
+ export function createToolHandler(def, services, notifiers, capabilities) {
702
951
  // The handler's return value carries no marker, so the arrays partial-success
703
952
  // telemetry reads are named by the output schema — resolved once here (#524).
704
953
  const partialResultKeys = resolvePartialResultKeys(def.output.shape);
705
954
  return async (input, serverContext) => {
706
955
  const request = resolveHandlerRequest(serverContext, services, notifiers);
707
956
  const appContext = requestContextService.createRequestContext({
708
- parentContext: handlerParentContext(request),
957
+ parentContext: handlerParentContext(serverContext),
709
958
  operation: 'HandleToolRequest',
710
959
  additionalContext: { toolName: def.name },
711
960
  });
@@ -735,7 +984,7 @@ export function createToolHandler(def, services, notifiers, inputGate) {
735
984
  // the handler returned recorded those as successes (#346).
736
985
  measured = true;
737
986
  return await measureToolExecution(async (spanContext, recordOutput) => {
738
- const handlerCtx = buildHandlerContext(request, services, spanContext, def.errors, inputGate);
987
+ const handlerCtx = buildHandlerContext(request, services, spanContext, def.errors, capabilities);
739
988
  ctx = handlerCtx;
740
989
  try {
741
990
  // Handler may return sync or async.
@@ -752,8 +1001,10 @@ export function createToolHandler(def, services, notifiers, inputGate) {
752
1001
  catch (error) {
753
1002
  // Inside the measurement on purpose: the completion log's
754
1003
  // `metrics.errorCode` and the span's error-code attribute are
755
- // derived from what leaves this callback (#421).
756
- throw asRequestCancelled(error, request.signal);
1004
+ // derived from what leaves this callback (#421). An input-required
1005
+ // signal leaves with its `requestState` sealed when a key is
1006
+ // configured, so a sealing failure is a failed call like any other.
1007
+ throw asRequestCancelled(await sealThrown(error, services.requestState, serverContext), request.signal);
757
1008
  }
758
1009
  }, { ...appContext, toolName: def.name }, validatedInput, () => {
759
1010
  const store = ctx ? readEnrichmentStore(ctx) : undefined;
@@ -764,22 +1015,28 @@ export function createToolHandler(def, services, notifiers, inputGate) {
764
1015
  }
765
1016
  catch (error) {
766
1017
  // `ctx.requestInput(...)` is protocol control flow, not a failure: return
767
- // the `input_required` result untouched, with no span, log, or
768
- // classification. The client (2026 era) or the SDK's legacy shim (2025
769
- // era) fulfils it and re-invokes this handler. A request this connection
770
- // cannot serve never reaches here as a signal — `ctx.requestInput`
771
- // throws the refusal instead, and it arrives below as an `McpError`.
1018
+ // the `input_required` result — its `requestState` already sealed when a
1019
+ // key is configured — with no span, log, or classification. The client
1020
+ // (2026 era) or the SDK's legacy shim (2025 era) fulfils it and
1021
+ // re-invokes this handler. A request this connection cannot serve never
1022
+ // reaches here as a signal — `ctx.requestInput` throws the refusal
1023
+ // instead, and it arrives below as an `McpError`.
772
1024
  if (isInputRequiredSignal(error))
773
1025
  return error.result;
774
- const severity = declaredSeverity(def, error);
775
- ErrorHandler.handleError(error, {
1026
+ // One reason-to-entry lookup: the declared recovery fills a failure that
1027
+ // carries none (#579) and the entry's severity sets the log level (#380,
1028
+ // #567). Filled before `handleError`, so the error record, the envelope,
1029
+ // and the failure-payload record carry the same hint.
1030
+ const { entry, failure } = resolveDeclaredFailure(def.errors, error);
1031
+ const severity = failureSeverity(entry, failure);
1032
+ ErrorHandler.handleError(failure, {
776
1033
  operation: `tool:${def.name}`,
777
1034
  context: appContext,
778
1035
  ...(severity !== undefined && { severity }),
779
1036
  });
780
1037
  if (!measured)
781
- recordToolRejection(def.name, error);
782
- const result = classifyAndBuildToolErrorResult(error);
1038
+ recordToolRejection(def.name, failure);
1039
+ const result = classifyAndBuildToolErrorResult(failure, appContext.requestId);
783
1040
  if (config.logToolFailurePayloads) {
784
1041
  logFailurePayload(services.logger, def.name, appContext, input, result, severity);
785
1042
  }