Skip to content

fix(mcp): validate tool arguments with zod and constrain http methods - #555

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
esanbasirat:fix/issue-411-validate-tool-arguments
Oct 19, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
esanbasirat:fix/issue-411-validate-tool-arguments

Conversation

@esanbasirat

Copy link
Copy Markdown

Summary

This PR addresses #411 by validating tool arguments against Zod schemas before handler execution and deriving advertised MCP `inputSchema` definitions directly from those Zod definitions, preventing schema drift.

Changes

  • Created `src/mcp/toolSchemas.ts`:
    • `BAZAAR_SEARCH_ARGUMENTS_SCHEMA`: Validates `url` (non-empty string) and optional `query` (string).
    • `PAID_CALL_ARGUMENTS_SCHEMA`: Validates `url` (non-empty string) and optional `method` constrained to `['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']`, configured as strict (`additionalProperties: false`).
    • `zodToToolInputSchema()`: Converts Zod object schemas to clean MCP `inputSchema` dictionaries without schema drift.
    • `formatZodError()`: Formats validation errors to clearly identify the offending argument and reason.
  • Updated `src/mcp/server.ts`:
    • Advertises `inputSchema` dynamically generated from the Zod schemas in `ListToolsRequestSchema`.
    • Validates `request.params.arguments` at the top of `bazaar_search` and `paid_call` handlers, returning structured `ERR_BAD_REQUEST` if validation fails.
  • Added comprehensive unit tests in `tests/mcp.test.ts`:
    • Advertised tool schemas verification (`inputSchema`, properties, enum, additionalProperties).
    • Missing argument handling (missing `url` returns `ERR_BAD_REQUEST` naming `'url'` without making calls).
    • Empty string arguments.
    • Wrongly-typed arguments (number/boolean for `url`, number for `query`).
    • Out-of-range / invalid `method` (e.g. `'INVALID'`).
    • Strict schema unrecognized property rejection.
    • Valid method execution and default method `'GET'`.

Closes #411

@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@esanbasirat Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Miracle656 added a commit that referenced this pull request Oct 19, 2026
…vertised schema from it

`bazaar_search` and `paid_call` coerced their arguments with `String()`, so
`url: 42` became the string "42" and `url` missing entirely became the string
"undefined" — both of which were then fetched. Arguments are now parsed with
a Zod schema before the handler runs, and a failure answers ERR_BAD_REQUEST
naming the offending argument.

The part I like most is that `inputSchema` is now generated from the same Zod
object via `zodToToolInputSchema()` rather than hand-written beside it. The
advertised schema for `paid_call` already claimed `additionalProperties: false`
while the handler accepted anything, which is exactly the drift #411 is about;
one source of truth is what stops it coming back.

`method` is now an enum of the seven HTTP methods instead of any string.

Security posture is unchanged where it matters: the SSRF guard and the
`checkPaidCallTarget` allow-list still run, and this only refuses malformed
calls earlier than they would have.

One test updated while merging. `tests/mcp-paid-call-allowlist.test.ts`
asserted that a call smuggling `paidCallAllowlist: ['evil.test']` fails with
ERR_HOST_NOT_ALLOWED; with `.strict()` the unrecognised keys are now refused
as ERR_BAD_REQUEST before the host check runs. The security property is intact
and arguably stronger — unknown arguments are rejected rather than ignored,
and no payment leaves the process either way — so the assertion now accepts
either code, matching the precedent already in that file. The host check keeps
its nine other assertions.

Verified on the merge result: `tsc --noEmit` silent, 834 tests pass. The two
remaining failures (`tests/openapi.test.ts` HTTP-method coverage and
`graphqlSchemaDrift`) fail identically on clean `main` and are not from this
PR.

Closes #411

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit d5e4cef into Divinity-Labs:main Oct 19, 2026
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged. Good, focused PR — one issue, three files, up to date with main, and it fixes the thing #411 actually complained about rather than the surface of it.

The part worth calling out: generating inputSchema from the Zod object with zodToToolInputSchema() instead of hand-writing it alongside. paid_call was already advertising additionalProperties: false while its handler accepted anything at all, which is precisely the drift the issue is about — a hand-written schema beside a hand-written parser will always come apart eventually. One source of truth is what stops it recurring, and that's a better answer than just adding validation.

Also right that String() was the bug and not merely untidy: url: 42 became "42" and a missing url became the literal string "undefined", and both were then fetched.

I checked that this doesn't weaken anything: ssrfSafeFetch and checkPaidCallTarget still run, and strict parsing only refuses malformed calls earlier than they would have.

One test I updated while merging

tests/mcp-paid-call-allowlist.test.ts has a test that smuggles paidCallAllowlist: ['evil.test'] into the call arguments and asserted ERR_HOST_NOT_ALLOWED. With .strict(), those unrecognised keys are now refused as ERR_BAD_REQUEST before the host check runs — so that assertion went red on the merge.

That's the test encoding a code, not a security regression. The property it exists to protect — an argument cannot widen the allow-list, and no payment leaves the process — holds either way, and is arguably stronger now: unknown arguments are rejected rather than silently ignored. I changed it to accept either code, which is the precedent already used a few lines above it, and the host check keeps its nine other assertions so no coverage is lost.

Worth knowing for next time: it's the kind of break that only shows up in the full suite, not in the file you're working on. Running the whole thing before pushing would have caught it.

One nit, not worth a change

method is now a strict enum, so method: "get" is refused where it previously went through. Stricter is defensible on a tool that spends money, and the enum is advertised now so a client can see what's allowed — I'm not asking you to change it. Just flagging that it's a visible behaviour change for any caller sending lowercase.

Verified on the merge result rather than your branch: tsc --noEmit silent, 834 tests pass. The two still-red suites (tests/openapi.test.ts HTTP-method coverage, graphqlSchemaDrift) fail identically on clean main — pre-existing and not yours. I'm fixing those next.

Closes #411.

Miracle656 added a commit that referenced this pull request Oct 19, 2026
The MCP server reported a hard-coded `version: '1.0.0'` while the package was
at 0.2.0, so every client was told something untrue about what it was talking
to. The version now comes from `package.json`, and the name becomes
`lens-mcp-server` — a client-affecting rename the PR flagged as such.

Two fixes while merging:

- **It did not compile.** The version read used `fileURLToPath(import.meta.url)`,
  and this project is `module: commonjs`, under which `import.meta` is
  `error TS1343`. Switched to `__dirname`, which has precedent in three
  existing files under `src/__tests__` — and which the PR's own new test
  already used, so the source was the odd one out. `../../package.json` reaches
  the repo root from both `src/mcp/` and `dist/mcp/`, since `rootDir` is `src`
  and `outDir` is `dist`.
- **The read is now non-fatal.** It runs at module load, so an unreadable or
  unparseable `package.json` would have stopped the server booting over a
  version string. Falls back to `0.0.0-unknown`, which is self-describing in a
  client's log.

Test conflict against #555 resolved by keeping both: the `ListToolsRequestSchema`
and `PAID_CALL_HTTP_METHODS` imports that PR added, plus this one's new
server-identity block.

Verified on the merge result: `tsc --noEmit` silent, 889 tests pass across 82
suites, nothing red.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656 added a commit that referenced this pull request Oct 19, 2026
…l error handling

`bazaar_search` awaited `ssrfSafeFetch` with no timeout, so an upstream that
accepted the connection and then went quiet hung the tool call indefinitely —
and `await res.json()` on a non-JSON body threw a raw parse error at the MCP
client.

Now: a 10s `AbortSignal.timeout` matching the house deadline in
`src/alerts/delivery.ts`, a content-type check before parsing, a guarded
`res.json()`, and timeouts distinguished from other failures so the message
says "did not respond within 10000ms" rather than something about JSON.

Every error now names the upstream host. That is the caller's own `url`
argument coming back to them, so it reveals nothing they did not supply, and
it is the difference between a debuggable failure and "ERR_SERVICE_UNREACHABLE".

Two merge resolutions, both against work that landed earlier today:

- `tests/mcp.test.ts` conflicted with #555's validation suite. Additive on
  both sides, so both blocks are kept.
- #555's two `paid_call` assertions pinned the fetch options exactly
  (`{ method: 'POST' }`) and now also see the new `signal`. Loosened to
  `expect.objectContaining`, which is what those tests actually mean — they
  assert the method is passed through, not that nothing else is.

Verified on the merge result: `tsc --noEmit` silent, 901 tests pass across 83
suites, nothing red.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
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.

Tool arguments are coerced with String() and never validated

3 participants