[GH-ISSUE #3221] [Bug] Oversized MCP validation errors exceed the client buffer and leave no retained diagnostic #1158

Closed
opened 2026-10-15 18:31:36 +00:00 by bsakaguchi · 0 comments
Owner

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

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

An invalid list_unknowns MCP call with an unrecognized argument key containing 10,485,600 ASCII K characters returns a 10,485,853-byte JSON-RPC line before the newline. This exceeds the pinned client's default 10,485,760-byte receive budget by 93 bytes. The result has isError: true, no structuredContent, and the expected target-content provenance marker. Its diagnostic includes the oversized key.

Feeding that received response to the installed client's default ReadBuffer in 65,536-byte chunks throws ReadBuffer exceeded maximum size of 10485760 bytes. A subsequent get_evidence_bundle summary reports zero retained records and an empty record list. There is no bounded transport diagnostic or retained original-error reference.

A small unknown key produces a normal 263-byte validation error. A subsequent valid list_unknowns call succeeds on the same connection with a 307-byte response containing an empty page. The server exits with code 0 after the probe sends SIGTERM and emits no stderr. The buffer rejection was reproduced directly; disconnect or reconnect behavior in a particular MCP host was not tested.

Reproduction

  1. Start from a clean checkout of morluto/rea at 58300885207133422af235e0f2b10658cf958b03, using a supported Node runtime. Install the locked dependencies from the checkout root:
npm ci
  1. Build the runtime:
npm run build:cached
  1. Run this inline probe from the checkout root. It creates an isolated server home and XDG directories, unsets REA_MCP_MAX_RESPONSE_BYTES, starts the public scripts/rea.mjs mcp entry point, creates the malformed argument inline, and removes its isolated directories afterward:
node --input-type=module <<'JS'
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { once } from "node:events";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ReadBuffer } from "@modelcontextprotocol/client";
const isolatedHome = await mkdtemp(join(tmpdir(), "rea-response-repro-"));
const environment = {
  ...process.env,
  HOME: isolatedHome,
  XDG_CONFIG_HOME: join(isolatedHome, "config"),
  XDG_CACHE_HOME: join(isolatedHome, "cache"),
  XDG_DATA_HOME: join(isolatedHome, "data")
};
delete environment.REA_MCP_MAX_RESPONSE_BYTES;
await Promise.all(["config", "cache", "data"].map(name => mkdir(join(isolatedHome, name))));
const child = spawn(process.execPath, ["scripts/rea.mjs", "mcp"], {
  cwd: process.cwd(), env: environment, stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", chunk => { stderr += chunk; });
const pending = new Map();
const lines = createInterface({ input: child.stdout });
let nextId = 1;
lines.on("line", line => {
  const message = JSON.parse(line);
  const waiter = pending.get(message.id);
  if (waiter) { pending.delete(message.id); waiter.resolve({message, bytes: Buffer.byteLength(line)}); }
});
function request(method, params = {}) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}; ${stderr.slice(0, 500)}`)); }, 15000);
    pending.set(id, { resolve(value) { clearTimeout(timer); resolve(value); } });
    child.stdin.write(JSON.stringify({jsonrpc:"2.0", id, method, params}) + "\n");
  });
}
try {
  const init = await request("initialize", {protocolVersion:"2025-06-18", capabilities:{}, clientInfo:{name:"response-budget-repro", version:"1"}});
  console.log(JSON.stringify({node: process.version, platform:process.platform, arch:process.arch, serverInfo:init.message.result.serverInfo}));
  child.stdin.write(JSON.stringify({jsonrpc:"2.0", method:"notifications/initialized"}) + "\n");
  const invalid = await request("tools/call", {name:"list_unknowns", arguments:{["K".repeat(10485600)]:1}});
  console.log(JSON.stringify({responseBytes:invalid.bytes, receiveBufferBytes:10485760, exceedsReceiveBuffer:invalid.bytes>10485760, isError:invalid.message.result?.isError, hasStructuredContent:invalid.message.result?.structuredContent!==undefined, errorPrefix:invalid.message.result?.content?.[0]?.text.slice(0,100), marker:invalid.message.result?._meta}));
  const wire = Buffer.from(JSON.stringify(invalid.message) + "\n");
  const reader = new ReadBuffer();
  try {
    for (let offset = 0; offset < wire.length; offset += 65536) {
      reader.append(wire.subarray(offset, offset + 65536));
      reader.readMessage();
    }
    console.log(JSON.stringify({clientReadBufferError:null}));
  } catch (error) {
    console.log(JSON.stringify({clientReadBufferError:error.message}));
  }
  const small = await request("tools/call", {name:"list_unknowns", arguments:{unexpected:1}});
  console.log(JSON.stringify({smallInvalidBytes:small.bytes, smallInvalidIsError:small.message.result?.isError, smallInvalidText:small.message.result?.content?.[0]?.text}));
  const bundle = await request("tools/call", {name:"get_evidence_bundle", arguments:{detail:"summary"}});
  console.log(JSON.stringify({retainedRecords:bundle.message.result?.structuredContent?.result?.total_retained_records, bundleResult:bundle.message.result?.structuredContent}));
  const valid = await request("tools/call", {name:"list_unknowns", arguments:{}});
  console.log(JSON.stringify({normalCallBytes:valid.bytes, normalCallResult:valid.message.result?.structuredContent, normalCallIsError:valid.message.result?.isError===true}));
} finally {
  const exited = once(child,"exit");
  child.kill("SIGTERM");
  await exited;
  await rm(isolatedHome, {recursive:true, force:true});
  console.log(JSON.stringify({childExitCode:child.exitCode, childSignalCode:child.signalCode, stderrBytes:Buffer.byteLength(stderr)}));
}
JS
  1. Observe responseBytes: 10485853, the ReadBuffer maximum-size error, and retainedRecords: 0. Check that the small-invalid and valid-call controls still succeed as described above.

Independent verification used the checkout's already-installed locked dependencies and prepared build. The verifier reran the exact inline probe; the installation and build commands were not rerun during verification.

Expected behavior

docs/mcp-contracts.md:96-101 promises default response admission within the pinned stdio client's 10 MiB receive budget, with room for the JSON-RPC envelope. A response that cannot fit should produce a transport resource_constraint.

docs/mcp-contracts.md:75-82 promises that an oversized error is retained as Evidence before a bounded diagnostic returns an export reference. If retention cannot occur, the bounded diagnostic should state why. This applies to delivery of the validation failure even though rejecting the unknown key is correct.

docs/mcp-contracts.md:181-189 explicitly includes SDK validation errors in provenance marking and says the budget accounts for that marker. The reproduced response already has the marker; the failure concerns size admission and retention.

Impact and scope

The malformed MCP input causes the default client buffer to reject the diagnostic, and the session retains no complete error to export. The reproduced case requires no binary, analysis provider, external service, or preexisting session data. It affects delivery and recovery of the oversized validation error.

Ordinary session calls remain usable in the same raw stdio connection. Avoiding the oversized unknown key and submitting valid list_unknowns arguments restored ordinary use in the replay, but it does not recover the original oversized diagnostic. No specific host's session loss, data corruption, or crossed security boundary was verified.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node v26.10.0; macOS arm64; locked @modelcontextprotocol/client and @modelcontextprotocol/server 2.3.1. REA_MCP_MAX_RESPONSE_BYTES was unset. The repository's development pin is Node v24.18.0; that runtime was not used for the replay.

Target and artifact context

No analysis target or external provider. A fresh local MCP session receives one unrecognized list_unknowns argument key containing 10,485,600 ASCII K characters with value 1. The probe checks its diagnostic and the session Evidence summary.

Interface

MCP

Earliest failure stage

Transport or result projection

Relevant output or evidence

responseBytes=10485853
receiveBufferBytes=10485760
exceedsReceiveBuffer=true
isError=true
hasStructuredContent=false
errorPrefix=Input validation error: Invalid arguments for tool list_unknowns: Unrecognized key:
clientReadBufferError=ReadBuffer exceeded maximum size of 10485760 bytes
smallInvalidBytes=263; smallInvalidIsError=true
retainedRecords=0
normalCallBytes=307; normalCallIsError=false
normalCallResult={result:{items:[],total:0,offset:0,next_offset:null}}
childExitCode=0; stderrBytes=0

Typed outcome or recovery expected

The same malformed request should yield a bounded transport resource_constraint whose entire JSON-RPC response, including its newline, fits the default client buffer. With session recording available, get_evidence_bundle should discover a retained mcp_tool_error record, and the diagnostic should provide an export reference to the complete original error. If retention is unavailable or fails, the bounded response should identify that condition.

Preserve the useful small-invalid validation message and the successful valid-call empty page. These controls distinguish restored delivery from suppressing errors or breaking normal session operations.

Analysis or suspected cause

Source-derived explanation at baseline 58300885207133422af235e0f2b10658cf958b03:

  • src/contracts/sessionToolSchemas.ts:49-68 defines a strict input object, and src/server/registerSessionRecordTools.ts:165-181 registers it for list_unknowns. The SDK rejects the unknown key before the tool handler runs and produces a text-only error.
  • src/server/EvidenceMcpServer.ts:178-186 connects the error-recovery transport wrapper. Its recoverError returns immediately for an error without structuredContent at :330-340.
  • src/server/EvidenceMcpServer.ts:297-316 then adds the target-content marker and forwards that error without a further size check. This explains why SDK-created text-only validation errors bypass the projected-error recovery path.
  • src/server/createServer.ts:253-278 supplies session Evidence recording to the adapter. The zero retained records are not explained by unavailable session recording in the production setup.

Additional context

Related reports #2557, #1261, #1053, #1065, #723, and #2512 cover projected-error accounting, structured-error schema compatibility, completed-analysis delivery, the broader roadmap, representation design, and discovery frame size; this report concerns SDK-created validation errors that have no private structured carrier and bypass both response admission and original-error retention.

P2 is supported by the bounded diagnostic-delivery and retention failure. Normal session operations succeeded afterward, and valid arguments provide a demonstrated workaround for ordinary use.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3221 ### 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 An invalid `list_unknowns` MCP call with an unrecognized argument key containing 10,485,600 ASCII `K` characters returns a 10,485,853-byte JSON-RPC line before the newline. This exceeds the pinned client's default 10,485,760-byte receive budget by 93 bytes. The result has `isError: true`, no `structuredContent`, and the expected target-content provenance marker. Its diagnostic includes the oversized key. Feeding that received response to the installed client's default `ReadBuffer` in 65,536-byte chunks throws `ReadBuffer exceeded maximum size of 10485760 bytes`. A subsequent `get_evidence_bundle` summary reports zero retained records and an empty record list. There is no bounded transport diagnostic or retained original-error reference. A small unknown key produces a normal 263-byte validation error. A subsequent valid `list_unknowns` call succeeds on the same connection with a 307-byte response containing an empty page. The server exits with code 0 after the probe sends SIGTERM and emits no stderr. The buffer rejection was reproduced directly; disconnect or reconnect behavior in a particular MCP host was not tested. ### Reproduction 1. Start from a clean checkout of `morluto/rea` at `58300885207133422af235e0f2b10658cf958b03`, using a supported Node runtime. Install the locked dependencies from the checkout root: ```sh npm ci ``` 2. Build the runtime: ```sh npm run build:cached ``` 3. Run this inline probe from the checkout root. It creates an isolated server home and XDG directories, unsets `REA_MCP_MAX_RESPONSE_BYTES`, starts the public `scripts/rea.mjs mcp` entry point, creates the malformed argument inline, and removes its isolated directories afterward: ```sh node --input-type=module <<'JS' import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { once } from "node:events"; import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ReadBuffer } from "@modelcontextprotocol/client"; const isolatedHome = await mkdtemp(join(tmpdir(), "rea-response-repro-")); const environment = { ...process.env, HOME: isolatedHome, XDG_CONFIG_HOME: join(isolatedHome, "config"), XDG_CACHE_HOME: join(isolatedHome, "cache"), XDG_DATA_HOME: join(isolatedHome, "data") }; delete environment.REA_MCP_MAX_RESPONSE_BYTES; await Promise.all(["config", "cache", "data"].map(name => mkdir(join(isolatedHome, name)))); const child = spawn(process.execPath, ["scripts/rea.mjs", "mcp"], { cwd: process.cwd(), env: environment, stdio: ["pipe", "pipe", "pipe"] }); let stderr = ""; child.stderr.on("data", chunk => { stderr += chunk; }); const pending = new Map(); const lines = createInterface({ input: child.stdout }); let nextId = 1; lines.on("line", line => { const message = JSON.parse(line); const waiter = pending.get(message.id); if (waiter) { pending.delete(message.id); waiter.resolve({message, bytes: Buffer.byteLength(line)}); } }); function request(method, params = {}) { const id = nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}; ${stderr.slice(0, 500)}`)); }, 15000); pending.set(id, { resolve(value) { clearTimeout(timer); resolve(value); } }); child.stdin.write(JSON.stringify({jsonrpc:"2.0", id, method, params}) + "\n"); }); } try { const init = await request("initialize", {protocolVersion:"2025-06-18", capabilities:{}, clientInfo:{name:"response-budget-repro", version:"1"}}); console.log(JSON.stringify({node: process.version, platform:process.platform, arch:process.arch, serverInfo:init.message.result.serverInfo})); child.stdin.write(JSON.stringify({jsonrpc:"2.0", method:"notifications/initialized"}) + "\n"); const invalid = await request("tools/call", {name:"list_unknowns", arguments:{["K".repeat(10485600)]:1}}); console.log(JSON.stringify({responseBytes:invalid.bytes, receiveBufferBytes:10485760, exceedsReceiveBuffer:invalid.bytes>10485760, isError:invalid.message.result?.isError, hasStructuredContent:invalid.message.result?.structuredContent!==undefined, errorPrefix:invalid.message.result?.content?.[0]?.text.slice(0,100), marker:invalid.message.result?._meta})); const wire = Buffer.from(JSON.stringify(invalid.message) + "\n"); const reader = new ReadBuffer(); try { for (let offset = 0; offset < wire.length; offset += 65536) { reader.append(wire.subarray(offset, offset + 65536)); reader.readMessage(); } console.log(JSON.stringify({clientReadBufferError:null})); } catch (error) { console.log(JSON.stringify({clientReadBufferError:error.message})); } const small = await request("tools/call", {name:"list_unknowns", arguments:{unexpected:1}}); console.log(JSON.stringify({smallInvalidBytes:small.bytes, smallInvalidIsError:small.message.result?.isError, smallInvalidText:small.message.result?.content?.[0]?.text})); const bundle = await request("tools/call", {name:"get_evidence_bundle", arguments:{detail:"summary"}}); console.log(JSON.stringify({retainedRecords:bundle.message.result?.structuredContent?.result?.total_retained_records, bundleResult:bundle.message.result?.structuredContent})); const valid = await request("tools/call", {name:"list_unknowns", arguments:{}}); console.log(JSON.stringify({normalCallBytes:valid.bytes, normalCallResult:valid.message.result?.structuredContent, normalCallIsError:valid.message.result?.isError===true})); } finally { const exited = once(child,"exit"); child.kill("SIGTERM"); await exited; await rm(isolatedHome, {recursive:true, force:true}); console.log(JSON.stringify({childExitCode:child.exitCode, childSignalCode:child.signalCode, stderrBytes:Buffer.byteLength(stderr)})); } JS ``` 4. Observe `responseBytes: 10485853`, the `ReadBuffer` maximum-size error, and `retainedRecords: 0`. Check that the small-invalid and valid-call controls still succeed as described above. Independent verification used the checkout's already-installed locked dependencies and prepared build. The verifier reran the exact inline probe; the installation and build commands were not rerun during verification. ### Expected behavior `docs/mcp-contracts.md:96-101` promises default response admission within the pinned stdio client's 10 MiB receive budget, with room for the JSON-RPC envelope. A response that cannot fit should produce a transport `resource_constraint`. `docs/mcp-contracts.md:75-82` promises that an oversized error is retained as Evidence before a bounded diagnostic returns an export reference. If retention cannot occur, the bounded diagnostic should state why. This applies to delivery of the validation failure even though rejecting the unknown key is correct. `docs/mcp-contracts.md:181-189` explicitly includes SDK validation errors in provenance marking and says the budget accounts for that marker. The reproduced response already has the marker; the failure concerns size admission and retention. ### Impact and scope The malformed MCP input causes the default client buffer to reject the diagnostic, and the session retains no complete error to export. The reproduced case requires no binary, analysis provider, external service, or preexisting session data. It affects delivery and recovery of the oversized validation error. Ordinary session calls remain usable in the same raw stdio connection. Avoiding the oversized unknown key and submitting valid `list_unknowns` arguments restored ordinary use in the replay, but it does not recover the original oversized diagnostic. No specific host's session loss, data corruption, or crossed security boundary was verified. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node v26.10.0; macOS arm64; locked `@modelcontextprotocol/client` and `@modelcontextprotocol/server` 2.3.1. `REA_MCP_MAX_RESPONSE_BYTES` was unset. The repository's development pin is Node v24.18.0; that runtime was not used for the replay. ### Target and artifact context No analysis target or external provider. A fresh local MCP session receives one unrecognized `list_unknowns` argument key containing 10,485,600 ASCII `K` characters with value `1`. The probe checks its diagnostic and the session Evidence summary. ### Interface MCP ### Earliest failure stage Transport or result projection ### Relevant output or evidence ```text responseBytes=10485853 receiveBufferBytes=10485760 exceedsReceiveBuffer=true isError=true hasStructuredContent=false errorPrefix=Input validation error: Invalid arguments for tool list_unknowns: Unrecognized key: clientReadBufferError=ReadBuffer exceeded maximum size of 10485760 bytes smallInvalidBytes=263; smallInvalidIsError=true retainedRecords=0 normalCallBytes=307; normalCallIsError=false normalCallResult={result:{items:[],total:0,offset:0,next_offset:null}} childExitCode=0; stderrBytes=0 ``` ### Typed outcome or recovery expected The same malformed request should yield a bounded transport `resource_constraint` whose entire JSON-RPC response, including its newline, fits the default client buffer. With session recording available, `get_evidence_bundle` should discover a retained `mcp_tool_error` record, and the diagnostic should provide an export reference to the complete original error. If retention is unavailable or fails, the bounded response should identify that condition. Preserve the useful small-invalid validation message and the successful valid-call empty page. These controls distinguish restored delivery from suppressing errors or breaking normal session operations. ### Analysis or suspected cause Source-derived explanation at baseline `58300885207133422af235e0f2b10658cf958b03`: - `src/contracts/sessionToolSchemas.ts:49-68` defines a strict input object, and `src/server/registerSessionRecordTools.ts:165-181` registers it for `list_unknowns`. The SDK rejects the unknown key before the tool handler runs and produces a text-only error. - `src/server/EvidenceMcpServer.ts:178-186` connects the error-recovery transport wrapper. Its `recoverError` returns immediately for an error without `structuredContent` at `:330-340`. - `src/server/EvidenceMcpServer.ts:297-316` then adds the target-content marker and forwards that error without a further size check. This explains why SDK-created text-only validation errors bypass the projected-error recovery path. - `src/server/createServer.ts:253-278` supplies session Evidence recording to the adapter. The zero retained records are not explained by unavailable session recording in the production setup. ### Additional context Related reports [#2557](https://github.com/morluto/rea/issues/2557), [#1261](https://github.com/morluto/rea/issues/1261), [#1053](https://github.com/morluto/rea/issues/1053), [#1065](https://github.com/morluto/rea/issues/1065), [#723](https://github.com/morluto/rea/issues/723), and [#2512](https://github.com/morluto/rea/issues/2512) cover projected-error accounting, structured-error schema compatibility, completed-analysis delivery, the broader roadmap, representation design, and discovery frame size; this report concerns SDK-created validation errors that have no private structured carrier and bypass both response admission and original-error retention. P2 is supported by the bounded diagnostic-delivery and retention failure. Normal session operations succeeded afterward, and valid arguments provide a demonstrated workaround for ordinary use.
bsakaguchi 2026-10-15 18:31:36 +00:00
  • closed this issue
  • added the
    bug
    label
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#1158
No description provided.