[GH-ISSUE #7661] [Bug] MCP input-error normalization replaces rejected field paths with one root issue #4586

Closed
opened 2026-10-31 00:51:01 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 28, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/7661

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

MCP or agent integration

Observed behavior

A stdio MCP tools/call request for read_bytes with arguments: {"address":123,"length":0,"document":false} correctly fails, but its canonical error describes three invalid fields as one issue with path: [] and reason: "invalid_value". The expected types and lower bound appear only in the comma-joined message; the issue has no expected or minimum members.

Calling read_bytes with {} also produces a root invalid_value issue rather than identifying the missing address. Calling search_bytes with {"pattern":123,"limit":0,"cursor":23} has the same three-fields-to-one-issue result. All have invalid_request / invalid_input, retryable: false, and remediation saying to correct the listed fields.

The same server preserves field attribution for a handler-level refusal: inspect_byte_entropy with {"path":"relative/x"} returns path: ["path"]. Giving that tool three schema-invalid fields instead returns the collapsed root issue. A subsequent ping succeeds.

Reproduction

  1. Use the baseline below with its locked dependencies and prepared build, with Node.js v24.22.1 selected. From the repository root, run the following inline probe. It creates package-runner fixtures first on PATH and an isolated HOME/XDG environment, starts the public MCP entry point, makes each call twice, and closes the client and removes its temporary files.
node --input-type=module <<'JS'
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, dirname, delimiter } from "node:path";
import { execFileSync } from "node:child_process";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const work = await mkdtemp(join(tmpdir(), "rea-input-verifier-"));
let client;
try {
  for (const name of ["bin", "home", "config", "cache", "data", "state", "tmp"])
    await mkdir(join(work, name));
  for (const name of ["npx", "npm", "bun"])
    await writeFile(join(work, "bin", name), "#!/bin/sh\nexit 97\n", { mode: 0o755 });
  const env = {
    PATH: [join(work, "bin"), dirname(process.execPath), process.env.PATH ?? ""].join(delimiter),
    HOME: join(work, "home"), XDG_CONFIG_HOME: join(work, "config"),
    XDG_CACHE_HOME: join(work, "cache"), XDG_DATA_HOME: join(work, "data"),
    XDG_STATE_HOME: join(work, "state"), TMPDIR: join(work, "tmp"),
    REA_LOG_LEVEL: "silent", REA_MCP_INPUT_SCHEMA_PROFILE: "full",
  };
  console.log("REA version:", execFileSync(process.execPath,
    ["scripts/rea.mjs", "--version"], { env, encoding: "utf8" }).trim());
  client = new Client({ name: "input-contract-verifier", version: "1" });
  const transport = new StdioClientTransport({
    command: process.execPath, args: ["scripts/rea.mjs", "mcp"],
    cwd: process.cwd(), env, stderr: "pipe",
  });
  transport.stderr?.on("data", () => {});
  await client.connect(transport);
  const tools = (await client.listTools()).tools;
  console.log("advertised:", tools.filter(t => ["read_bytes", "search_bytes"].includes(t.name)).map(t => t.name));
  for (let repeat = 0; repeat < 2; repeat++) {
    for (const [name, args] of [
      ["read_bytes", { address: 123, length: 0, document: false }],
      ["read_bytes", {}],
      ["search_bytes", { pattern: 123, limit: 0, cursor: 23 }],
      ["inspect_byte_entropy", { path: 123, block_size: "x", offset: -1 }],
      ["inspect_byte_entropy", { path: "relative/x" }],
    ]) {
      const result = await client.callTool({ name, arguments: args });
      const error = JSON.parse(result.content[0].text).error;
      console.log(JSON.stringify({ repeat, name, args, isError: result.isError, error }));
    }
  }
  console.log("ping:", JSON.stringify(await client.ping()));
} finally {
  try { if (client) await client.close(); }
  finally { await rm(work, { recursive: true, force: true }); }
}
JS
  1. Inspect error.details.issues in the output. Both repetitions return one root issue for each schema-invalid call, while both relative/x controls return the path field. The block prints REA version: 6.8.0, advertises both memory tools and finishes with ping: {}. This complete block was executed successfully during verification; each of its five cases reproduced twice.

Expected behavior

Keep the refusal, error classification and recovery step. Preserve separate correction issues for the fields the registered input contract rejects. For the first read_bytes call, the existing canonical projector independently returned:

[
  {"path":["address"],"reason":"invalid_type","expected":"string"},
  {"path":["length"],"reason":"out_of_range","minimum":1},
  {"path":["document"],"reason":"invalid_type","expected":"string"}
]

For the missing-address call it returned [{"path":["address"],"reason":"missing_argument","expected":"string"}]. The input-error policy requires preservation of caller field/index paths and safe constraints. The MCP recovery contract directs callers to inspect the input schema and correct the listed fields. Contract locators are in Evidence.

Impact and scope

P3: the observed loss is in structured correction metadata. Callers cannot use details.issues[].path and reason to distinguish the rejected fields or omission/type/range failures; they must read the combined prose. Manual correction remains practical because that prose retains the field names and constraints. Input rejection, operation identity and recovery are intact. No actual failed agent recovery, valid-analysis error, target access or data loss was demonstrated.

Executed scope is public stdio MCP on the stated macOS build, full input-schema profile, and the five calls above. Other tools, the default compact profile, HTTP transport and specific agent hosts were not independently exercised.

REA version

Exact node scripts/rea.mjs --version output:

6.8.0

Source commit: 65f1c447344a423fa2785847dbcea1e9a9e2454a.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1, build 26A434; arm64. Locked MCP client/server 2.3.1. The supplied compiled runtime was used without rebuilding or changing dependencies. No analysis provider was involved.

Target and artifact context

No target or artifact. Literal malformed arguments exercise admission; the relative-path control is refused before file inspection.

Interface

MCP

Earliest failure stage

Transport or result projection

Relevant output or evidence

Decoded error from the first read_bytes request, obtained through the stdio probe above:

{
  "code":"invalid_request",
  "category":"invalid_input",
  "message":"The arguments for MCP tool read_bytes do not match its advertised input schema.",
  "retryable":false,
  "remediation":{"action":"Read the declared input schema or CLI usage for read_bytes; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call."},
  "recovery":{"kind":"inspect_input_contract","operation":"read_bytes"},
  "details":{
    "operation":"read_bytes",
    "issues":[{
      "path":[],
      "reason":"invalid_value",
      "message":"address: Invalid input: expected string, received number, length: Too small: expected number to be >=1, document: Invalid input: expected string, received boolean"
    }]
  }
}

The missing-address message is address: Invalid input: expected string, received undefined, also with path: [] and reason: "invalid_value". The search_bytes message names pattern, limit and cursor, but again supplies only one root issue. The handler-level control returns:

[{"path":["path"],"reason":"invalid_value","message":"Expected an absolute filesystem path on this host."}]

Every captured response has isError: true and omits structuredContent. Supplemental verification parsed the same read_bytes and search_bytes inputs with their built canonical schemas and projectInputIssues; it produced separate field/type/range issues, including the arrays in Expected behavior.

Contract and failure sources at the baseline:

  • docs/tool-design.md:348-351 requires caller field/index paths and safe constraints for expected input failures, using the shared projector at admission.
  • docs/mcp-contracts.md:141-166 specifies canonical diagnostic text, input-contract recovery and preservation of bounded SDK field/type diagnostics.
  • src/contracts/hopperMemoryContracts.ts:12-34 defines the memory-tool argument types and lower bounds, which were also present in tools/list.
  • src/server/EvidenceMcpServerCore.ts:567-581 recognizes SDK validation text; :738-761 constructs a new AnalysisInputError containing one root invalid_value issue.
  • src/domain/inputIssueProjection.ts:35-140 preserves field paths and maps missing/type/bound failures to correction metadata.
  • tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts:170-185 checks the SDK-normalized message content, without requiring a structured field path or reason.

The returned errors are valid against docs/error-contract.schema.json:58-64, which leaves details contents unrestricted. Ajv2020 accepted all ten errors in the first independent replay. Schema validity therefore does not detect this policy violation. docs/cli.md:975-978 concerns retaining CLI diagnostics during output selection; it does not independently promise MCP field metadata.

Typed outcome or recovery expected

Retain invalid_request, invalid_input, retryable: false and recovery.kind: "inspect_input_contract". Return distinct issues at address, length and document, or pattern, limit and cursor, with the canonical type/range information. An omitted address should identify address and missing_argument. Keep the complete readable diagnostic and existing response-budget handling.

Analysis or suspected cause

Source-confirmed ownership: REA creates the empty path and generic reason explicitly in its SDK-error normalizer. The pinned SDK first returns a text-only refusal. A separate executed control registered the same read_bytes schema on the pinned SDK's McpServer, using InMemoryTransport; it returned the original Input validation error text and invoked the handler zero times. It produced no canonical details.issues object. The collapsed canonical issue is REA's subsequent representation choice.

The registered canonical schemas and shared projector can already produce the missing facts, as the supplemental probe confirmed. Preserve those structured facts at REA's admission boundary rather than reconstructing field identity from formatted prose. No SDK patch or REA fix was implemented during this verification.

Additional context

Related reports were checked with their comments and linked PRs. #3865/#5036 concern Electron CLI constructors; #5950 concerns deferred native-matching Evidence; #6107/#6270 concern the regex matcher; #7572 concerns CLI procedure validation. #7503/#7502 concern exclusive-bound meaning; #3408/#3525 concern accepting JSON-text structured arguments; #7123/#7122 concern advertised-schema traversal.

Closed #5431 and merged #5470 added canonical SDK recovery and retained prose diagnostics; those recovery fields are present here. Closed #4636 and merged #4661 address handler-level browser scenario errors, and #3221/#3267 and merged #5371 concern oversized error delivery/framing. Their restoration conditions differ from structured field preservation here. Merged #3515 explicitly notes that SDK normalization can flatten paths and leaves it unchanged while fixing timer admission. This report isolates that remaining limitation. No last-known-good version or regression is claimed.

Triage assessment

  • Impact level: P3
  • Assessment status: supported
  • Impact basis: Correctly rejected requests lose structured field/type/range correction metadata, while readable diagnostics, rejection status and connection usability remain intact; no failed recovery or analysis was demonstrated.
  • Workaround status: available
  • Workaround basis: The observed combined diagnostic names each rejected field and its expected type or bound, allowing manual correction without a code change; it does not restore structured issue consumers.
Originally created by @coygeek on GitHub (Oct 28, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7661 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area MCP or agent integration ### Observed behavior A stdio MCP `tools/call` request for `read_bytes` with `arguments: {"address":123,"length":0,"document":false}` correctly fails, but its canonical error describes three invalid fields as one issue with `path: []` and `reason: "invalid_value"`. The expected types and lower bound appear only in the comma-joined message; the issue has no `expected` or `minimum` members. Calling `read_bytes` with `{}` also produces a root `invalid_value` issue rather than identifying the missing `address`. Calling `search_bytes` with `{"pattern":123,"limit":0,"cursor":23}` has the same three-fields-to-one-issue result. All have `invalid_request` / `invalid_input`, `retryable: false`, and remediation saying to correct the listed fields. The same server preserves field attribution for a handler-level refusal: `inspect_byte_entropy` with `{"path":"relative/x"}` returns `path: ["path"]`. Giving that tool three schema-invalid fields instead returns the collapsed root issue. A subsequent ping succeeds. ### Reproduction 1. Use the baseline below with its locked dependencies and prepared build, with Node.js v24.22.1 selected. From the repository root, run the following inline probe. It creates package-runner fixtures first on PATH and an isolated HOME/XDG environment, starts the public MCP entry point, makes each call twice, and closes the client and removes its temporary files. ```sh node --input-type=module <<'JS' import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, dirname, delimiter } from "node:path"; import { execFileSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const work = await mkdtemp(join(tmpdir(), "rea-input-verifier-")); let client; try { for (const name of ["bin", "home", "config", "cache", "data", "state", "tmp"]) await mkdir(join(work, name)); for (const name of ["npx", "npm", "bun"]) await writeFile(join(work, "bin", name), "#!/bin/sh\nexit 97\n", { mode: 0o755 }); const env = { PATH: [join(work, "bin"), dirname(process.execPath), process.env.PATH ?? ""].join(delimiter), HOME: join(work, "home"), XDG_CONFIG_HOME: join(work, "config"), XDG_CACHE_HOME: join(work, "cache"), XDG_DATA_HOME: join(work, "data"), XDG_STATE_HOME: join(work, "state"), TMPDIR: join(work, "tmp"), REA_LOG_LEVEL: "silent", REA_MCP_INPUT_SCHEMA_PROFILE: "full", }; console.log("REA version:", execFileSync(process.execPath, ["scripts/rea.mjs", "--version"], { env, encoding: "utf8" }).trim()); client = new Client({ name: "input-contract-verifier", version: "1" }); const transport = new StdioClientTransport({ command: process.execPath, args: ["scripts/rea.mjs", "mcp"], cwd: process.cwd(), env, stderr: "pipe", }); transport.stderr?.on("data", () => {}); await client.connect(transport); const tools = (await client.listTools()).tools; console.log("advertised:", tools.filter(t => ["read_bytes", "search_bytes"].includes(t.name)).map(t => t.name)); for (let repeat = 0; repeat < 2; repeat++) { for (const [name, args] of [ ["read_bytes", { address: 123, length: 0, document: false }], ["read_bytes", {}], ["search_bytes", { pattern: 123, limit: 0, cursor: 23 }], ["inspect_byte_entropy", { path: 123, block_size: "x", offset: -1 }], ["inspect_byte_entropy", { path: "relative/x" }], ]) { const result = await client.callTool({ name, arguments: args }); const error = JSON.parse(result.content[0].text).error; console.log(JSON.stringify({ repeat, name, args, isError: result.isError, error })); } } console.log("ping:", JSON.stringify(await client.ping())); } finally { try { if (client) await client.close(); } finally { await rm(work, { recursive: true, force: true }); } } JS ``` 2. Inspect `error.details.issues` in the output. Both repetitions return one root issue for each schema-invalid call, while both `relative/x` controls return the `path` field. The block prints `REA version: 6.8.0`, advertises both memory tools and finishes with `ping: {}`. This complete block was executed successfully during verification; each of its five cases reproduced twice. ### Expected behavior Keep the refusal, error classification and recovery step. Preserve separate correction issues for the fields the registered input contract rejects. For the first `read_bytes` call, the existing canonical projector independently returned: ```json [ {"path":["address"],"reason":"invalid_type","expected":"string"}, {"path":["length"],"reason":"out_of_range","minimum":1}, {"path":["document"],"reason":"invalid_type","expected":"string"} ] ``` For the missing-address call it returned `[{"path":["address"],"reason":"missing_argument","expected":"string"}]`. The input-error policy requires preservation of caller field/index paths and safe constraints. The MCP recovery contract directs callers to inspect the input schema and correct the listed fields. Contract locators are in Evidence. ### Impact and scope P3: the observed loss is in structured correction metadata. Callers cannot use `details.issues[].path` and `reason` to distinguish the rejected fields or omission/type/range failures; they must read the combined prose. Manual correction remains practical because that prose retains the field names and constraints. Input rejection, operation identity and recovery are intact. No actual failed agent recovery, valid-analysis error, target access or data loss was demonstrated. Executed scope is public stdio MCP on the stated macOS build, full input-schema profile, and the five calls above. Other tools, the default compact profile, HTTP transport and specific agent hosts were not independently exercised. ### REA version Exact `node scripts/rea.mjs --version` output: ```text 6.8.0 ``` Source commit: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. ### Runtime and platform Node.js v24.22.1; macOS 27.0.1, build 26A434; arm64. Locked MCP client/server 2.3.1. The supplied compiled runtime was used without rebuilding or changing dependencies. No analysis provider was involved. ### Target and artifact context No target or artifact. Literal malformed arguments exercise admission; the relative-path control is refused before file inspection. ### Interface MCP ### Earliest failure stage Transport or result projection ### Relevant output or evidence Decoded error from the first `read_bytes` request, obtained through the stdio probe above: ```json { "code":"invalid_request", "category":"invalid_input", "message":"The arguments for MCP tool read_bytes do not match its advertised input schema.", "retryable":false, "remediation":{"action":"Read the declared input schema or CLI usage for read_bytes; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call."}, "recovery":{"kind":"inspect_input_contract","operation":"read_bytes"}, "details":{ "operation":"read_bytes", "issues":[{ "path":[], "reason":"invalid_value", "message":"address: Invalid input: expected string, received number, length: Too small: expected number to be >=1, document: Invalid input: expected string, received boolean" }] } } ``` The missing-address message is `address: Invalid input: expected string, received undefined`, also with `path: []` and `reason: "invalid_value"`. The `search_bytes` message names `pattern`, `limit` and `cursor`, but again supplies only one root issue. The handler-level control returns: ```json [{"path":["path"],"reason":"invalid_value","message":"Expected an absolute filesystem path on this host."}] ``` Every captured response has `isError: true` and omits `structuredContent`. Supplemental verification parsed the same `read_bytes` and `search_bytes` inputs with their built canonical schemas and `projectInputIssues`; it produced separate field/type/range issues, including the arrays in Expected behavior. Contract and failure sources at the baseline: - `docs/tool-design.md:348-351` requires caller field/index paths and safe constraints for expected input failures, using the shared projector at admission. - `docs/mcp-contracts.md:141-166` specifies canonical diagnostic text, input-contract recovery and preservation of bounded SDK field/type diagnostics. - `src/contracts/hopperMemoryContracts.ts:12-34` defines the memory-tool argument types and lower bounds, which were also present in `tools/list`. - `src/server/EvidenceMcpServerCore.ts:567-581` recognizes SDK validation text; `:738-761` constructs a new `AnalysisInputError` containing one root `invalid_value` issue. - `src/domain/inputIssueProjection.ts:35-140` preserves field paths and maps missing/type/bound failures to correction metadata. - `tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts:170-185` checks the SDK-normalized message content, without requiring a structured field path or reason. The returned errors are valid against `docs/error-contract.schema.json:58-64`, which leaves `details` contents unrestricted. Ajv2020 accepted all ten errors in the first independent replay. Schema validity therefore does not detect this policy violation. `docs/cli.md:975-978` concerns retaining CLI diagnostics during output selection; it does not independently promise MCP field metadata. ### Typed outcome or recovery expected Retain `invalid_request`, `invalid_input`, `retryable: false` and `recovery.kind: "inspect_input_contract"`. Return distinct issues at `address`, `length` and `document`, or `pattern`, `limit` and `cursor`, with the canonical type/range information. An omitted address should identify `address` and `missing_argument`. Keep the complete readable diagnostic and existing response-budget handling. ### Analysis or suspected cause Source-confirmed ownership: REA creates the empty path and generic reason explicitly in its SDK-error normalizer. The pinned SDK first returns a text-only refusal. A separate executed control registered the same `read_bytes` schema on the pinned SDK's `McpServer`, using `InMemoryTransport`; it returned the original `Input validation error` text and invoked the handler zero times. It produced no canonical `details.issues` object. The collapsed canonical issue is REA's subsequent representation choice. The registered canonical schemas and shared projector can already produce the missing facts, as the supplemental probe confirmed. Preserve those structured facts at REA's admission boundary rather than reconstructing field identity from formatted prose. No SDK patch or REA fix was implemented during this verification. ### Additional context Related reports were checked with their comments and linked PRs. #3865/#5036 concern Electron CLI constructors; #5950 concerns deferred native-matching Evidence; #6107/#6270 concern the regex matcher; #7572 concerns CLI procedure validation. #7503/#7502 concern exclusive-bound meaning; #3408/#3525 concern accepting JSON-text structured arguments; #7123/#7122 concern advertised-schema traversal. Closed #5431 and merged #5470 added canonical SDK recovery and retained prose diagnostics; those recovery fields are present here. Closed #4636 and merged #4661 address handler-level browser scenario errors, and #3221/#3267 and merged #5371 concern oversized error delivery/framing. Their restoration conditions differ from structured field preservation here. Merged #3515 explicitly notes that SDK normalization can flatten paths and leaves it unchanged while fixing timer admission. This report isolates that remaining limitation. No last-known-good version or regression is claimed. ## Triage assessment - Impact level: P3 - Assessment status: supported - Impact basis: Correctly rejected requests lose structured field/type/range correction metadata, while readable diagnostics, rejection status and connection usability remain intact; no failed recovery or analysis was demonstrated. - Workaround status: available - Workaround basis: The observed combined diagnostic names each rejected field and its expected type or bound, allowing manual correction without a code change; it does not restore structured issue consumers.
Author
Owner

@harshjohiya commented on GitHub (Oct 28, 2026):

I’ll work on preserving per-field validation issues for SDK-rejected MCP tool arguments. I’ll reproduce the current stdio response, retain canonical field paths and bounds at the input boundary, add regression coverage, and verify that the MCP connection remains usable. I’ll report executed checks and platform limits in the PR.

<!-- gh-comment-id:6467691634 --> @harshjohiya commented on GitHub (Oct 28, 2026): I’ll work on preserving per-field validation issues for SDK-rejected MCP tool arguments. I’ll reproduce the current stdio response, retain canonical field paths and bounds at the input boundary, add regression coverage, and verify that the MCP connection remains usable. I’ll report executed checks and platform limits in the PR.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4586
No description provided.