Skip to content

refactor: validate JSON-RPC at the MCP/HTTP boundary#897

Merged
thymikee merged 2 commits into
mainfrom
refactor/mcp-parse-at-boundary
Jun 27, 2026
Merged

refactor: validate JSON-RPC at the MCP/HTTP boundary#897
thymikee merged 2 commits into
mainfrom
refactor/mcp-parse-at-boundary

Conversation

@thymikee

Copy link
Copy Markdown
Member

What

Replace unchecked casts of attacker-controllable JSON-RPC wire input with real boundary validation, wiring in the jsonRpcRequestSchema that already existed in contracts.ts but was dead code.

  • src/mcp/server.ts — the stdio decode path no longer force-casts JSON.parse(raw) to JsonRpcMessage[] / JsonRpcMessage. Each inbound payload (and each batch element) is parsed through jsonRpcRequestSchema before routing. The decoder now forwards the untyped parsed value and handleMcpPayload validates at the boundary.
  • src/contracts.ts — add commandRpcParamsSchema (and an optionalStringArray helper) next to jsonRpcRequestSchema to validate the params of a command JSON-RPC request.
  • src/daemon/http-server.ts — replace the params as unknown as Partial<DaemonRequest> double-cast in methodToDaemonRequest with commandRpcParamsSchema.parse(params).

Why

These were the spots where untrusted wire input was force-cast straight into typed objects. Parsing at the boundary turns a silently-mistyped object into a proper validated envelope (or a clean JSON-RPC error), and revives a schema that was otherwise unused.

Preserves all valid requests / notifications / batches

jsonRpcRequestSchema does not mandate an id, so:

  • Requests (with id) route exactly as before.
  • Notifications (no id) are accepted and still produce no response (id === undefined is preserved distinctly from id: null).
  • Batches are validated per-element, so a valid batch is routed in order and notifications are skipped, identical to today; a malformed element in a batch is rejected individually while valid siblings still respond.

Only genuinely malformed input is rejected:

  • A wrong-typed method/jsonrpc/id or a non-object payload yields the standard -32600 Invalid Request error (best-effort id preserved), matching today's router error path, instead of crashing.
  • JSON.parse failures still surface as -32700.

The HTTP command path stays behaviorless for every valid command request; only malformed params (e.g. non-string command, non-array positionals) are now rejected at the boundary instead of being passed through untyped.

Validation

All run from the worktree:

  • node_modules/.bin/tsc -p tsconfig.json --noEmit — clean
  • oxfmt --write on the changed files
  • oxlint <changed> --deny-warnings — clean
  • vitest run src/mcp — 16 passed (adds src/mcp/__tests__/server-validation.test.ts proving valid request/notification/batch routing and -32600 rejection of malformed input)
  • vitest run contracts and src/__tests__/contracts-schema-public.test.ts — passed
  • vitest run on http-contract, runtime-hints, request-router-open daemon suites — passed
  • node scripts/sync-mcp-metadata.mjs --check — passed unchanged

Wire the previously dead jsonRpcRequestSchema into the MCP stdio decode path and
add a sibling commandRpcParamsSchema for the daemon HTTP command params, replacing
unchecked casts of attacker-controllable wire input with real boundary parsing.

- mcp/server.ts: each inbound payload (and each batch element) is parsed via
  jsonRpcRequestSchema instead of being force-cast to JsonRpcMessage. Valid
  requests (with id), notifications (no id), and batches are accepted and routed
  exactly as before; only genuinely malformed input (non-object, or wrong-typed
  jsonrpc/method/id) is rejected with the standard -32600 Invalid Request error.
  JSON.parse failures still surface as -32700, and malformed input never crashes.
- contracts.ts: add commandRpcParamsSchema next to jsonRpcRequestSchema validating
  the command RPC params; optionalStringArray helper added.
- daemon/http-server.ts: replace the 'params as unknown as Partial<DaemonRequest>'
  double-cast with commandRpcParamsSchema.parse(params).
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.4 MB 1.4 MB +1.0 kB
JS gzip 445.2 kB 445.5 kB +313 B
npm tarball 583.3 kB 583.7 kB +413 B
npm unpacked 2.0 MB 2.0 MB +1.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.7 ms 27.2 ms -1.5 ms
CLI --help 47.6 ms 48.7 ms +1.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/9722.js +356 B +141 B

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused, well-scoped boundary-validation refactor. I traced both production paths.

MCP stdio path — verified behavior-equivalent for all valid traffic and a net improvement on malformed input: requests, no-id notifications, explicit-null id, and per-element batch routing all preserved (router's existing -32600 check is unchanged; id===undefined notification distinction kept). The reconstructed {jsonrpc,id,method,params} envelope drops only unknown top-level fields, which nothing downstream reads, and params is passed through untyped. The null-payload crash (null.jsonrpc / fallbackErrorId(null)) is genuinely fixed. New tests exercise the real router (no mocks); ran them locally, 8 passed.

One finding on the HTTP path below.

Comment thread src/daemon/http-server.ts Outdated
): DaemonRequest {
if (COMMAND_RPC_METHODS.has(method)) {
return toDaemonRequest(params as unknown as Partial<DaemonRequest>, headers);
return toDaemonRequest(commandRpcParamsSchema.parse(params), headers);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schema-parse failures here surface as JSON-RPC -32000 / HTTP 500 (server error), not -32602 / 400. commandRpcParamsSchema.parse throws a plain Error, which normalizeError maps to code UNKNOWNstatusCodeForNormalizedError500, and the wire message leaks the internal schema path. Confirmed locally:

{command:'tap',positionals:'x'} -> UNKNOWN "$.positionals: Expected an array"
{command:123}                  -> UNKNOWN "$.command: Expected a string"
[...]                          -> UNKNOWN "$: Expected an object"

This is inconsistent with the sibling checks just above (:565, :581 return -32602/400) and with every other param validator in this file, which throw AppError('INVALID_ARGS', …) → 400. It's also a real behavior change for the attacker-input path this PR targets: a non-array positionals was previously coerced to [] (request proceeded), and a non-string command returned -32602/400 — both now become 500s. Reporting malformed client input as a server error is the wrong signal for alerting/retry.

Suggest wrapping the parse so it throws AppError('INVALID_ARGS', …) (→ 400, with a clean message), ideally surfaced as -32602 to match :565/:581.

methodToDaemonRequest validated command params via commandRpcParamsSchema.parse, which throws a plain Error on malformed input. That mapped to UNKNOWN -> HTTP 500 / -32000 and leaked the internal '$.x' schema path on the wire. Reporting malformed client input as a server error is the wrong signal.

Wrap the parse so failures throw AppError('INVALID_ARGS', 'Invalid params: ...') with the schema path sigil stripped, and map normalized INVALID_ARGS to JSON-RPC -32602 in the boundary catch so malformed command params now surface as 400/-32602, matching the explicit sibling checks.
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the catch @thymikee. Fixed in 38f45cb: methodToDaemonRequest now wraps commandRpcParamsSchema.parse so malformed command params throw AppError('INVALID_ARGS', 'Invalid params: ...') instead of a plain Error. The boundary catch maps normalized INVALID_ARGS to JSON-RPC -32602, so malformed command params now surface as 400 / -32602 (Invalid params), matching the explicit sibling checks (command is required, params must be an object) rather than the old 500 / -32000. The internal $.x schema path sigil is stripped from the wire message (e.g. Invalid params: positionals: Expected an array). Added src/daemon/__tests__/http-server-rpc-validation.test.ts covering positionals-as-string and command-as-number, asserting 400 / -32602 (not 500 / -32000), data.code === 'INVALID_ARGS', no $. leak, and that the handler is never dispatched. Left the install-source/lease validators' HTTP 400 as-is; they now share the -32602 code via the same normalized-code mapping. tsc/oxlint/oxfmt clean; targeted daemon tests pass.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review passed — the prior HTTP finding is fully resolved; merge-ready from my side.

Verified in 38f45cbc6:

  • parseCommandRpcParams wraps commandRpcParamsSchema.parse and rethrows AppError('INVALID_ARGS', 'Invalid params: …'); the boundary catch derives the JSON-RPC code via jsonRpcCodeForNormalizedError (INVALID_ARGS → -32602) and the status via statusCodeForNormalizedError (→ 400). So {positionals:'x'} and {command:42} now return 400 / -32602 / data.code:'INVALID_ARGS', not 500 / -32000.
  • cleanSchemaParseMessage strips the $/$. sigil — checked positionals, command, root $:, and nested positionals[0]; none leak the internal path.
  • http-server-rpc-validation.test.ts proves this end-to-end against a real loopback server (fetch → assert status/code/data/no-$.-leak/handler-never-dispatched). Ran it locally: passes.

No regressions found:

  • Valid command requests unaffected — toDaemonRequest still defaults omitted positionals→[], command→'', session→'default'.
  • The catch's -32602 mapping also covers the install-source/lease validators (already AppError('INVALID_ARGS')): HTTP status stays 400, only the JSON-RPC code shifts -32000→-32602 — more accurate and consistent with the explicit command is required / params must be an object siblings (already -32602). Streaming path validates before headers are sent, so the headersSent branch is unaffected.
  • MCP stdio path re-confirmed: valid request / no-id notification / explicit-null id / per-element batch all preserved; malformed → -32600 with best-effort id; null-payload crash fixed. Real-router tests pass.

Local: targeted + mcp/contracts/http-contract suites green (37 tests), tsc --noEmit clean. CI green.

Residual risk: low. One pre-existing nit (not introduced here): methodToDaemonRequest's unreachable throw AppError('INVALID_ARGS','Method not found') fallback would now map to -32602 rather than the semantically-correct -32601 — but it's dead code gated by SUPPORTED_RPC_METHODS. Not worth blocking.

@thymikee thymikee merged commit cd00ff8 into main Jun 27, 2026
20 checks passed
@thymikee thymikee deleted the refactor/mcp-parse-at-boundary branch June 27, 2026 12:01
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-27 12:01 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant