Repository navigation
Low-level Server.setRequestHandler strips unknown tools/call params members before any author schema can preserve them #3008
Description
TL;DR: On the maintained 2.x line, Server.setRequestHandler validates each request through its era request schema and passes only the validated value to the handler. Unknown members of tools/call params (fields beside name and arguments) are silently stripped before any author-side schema can see them, so a server cannot preserve them even when its own tool schemas use passthrough(). Requesting a maintained way to receive the original params (or the stripped members) at request level.
Bug Type: behavior gap / API gap
Version: @modelcontextprotocol/server 2.3.1 + @modelcontextprotocol/core 2.3.1 (also observed on the v2 alpha line)
Steps to Reproduce: Register a handler for tools/call on a raw Server (not the high-level McpServer.registerTool path). Send a call whose params carry business fields beside name/arguments, e.g. {name: "delete_record", arguments: {...}, resource_type: "company", record_id: "..."}. Inside the handler, inspect the received params: the extra members are gone. Verified at the wire level with a raw stdio JSON client against the built CLI, in both modern and legacy protocol eras, reproduced twice; the same calls with every field inside arguments survive when the tool's registration schema uses z.object({...}).passthrough() (the workaround discussed in #147), which confirms the strip happens in the SDK's own request validation, below the author-schema layer.
Observed SDK behavior: Server.setRequestHandler passes dispatchCodec.validateRequest(...).value to the handler; the era request schemas for CallToolRequestParams retain name and arguments and strip unknown siblings. The documented three-argument schema overload is for custom non-spec methods, so there is currently no blessed path to see spec-request extras.
Expected: A maintained extension point to preserve unknown tools/call params - for example an option on request validation, a documented raw-params hook, or a documented guarantee that unknown members are dropped (so servers can tell clients to stop sending them). Any of the three resolves the ambiguity; today the behavior is neither documented nor controllable.
Impact: Servers migrating from older SDKs (or serving older clients) that accept loose call parameters lose those fields silently, with no error or hook to recover them. Concrete case: a long-lived server whose normalizer accepts resource_type/record_id beside name for legacy clients - under 2.3.1 those fields vanish before the server's own validation layer runs.
The high-level McpServer.registerTool path is unaffected (its argument schema is author-owned); this gap is specific to the low-level Server request-handler path that servers use for custom normalization.
Activity
jarrettdustinqq commentedon Oct 13, 2026
I checked the current v2 source and think this is a real mismatch between the SDK's generic extension posture and the method-specific dispatch boundary.
The generated 2025 and 2026 schema sources deliberately type the base
Request.paramsas open for unofficial extensions (2025 lines 65–69, 2026 lines 184–188). The SDK mirrors that at the generic boundary withBaseRequestParamsSchema.loose()(schemas.ts lines 50–77), butCallToolRequestSchemareplacesparamswith the non-looseCallToolRequestParamsSchema(lines 1454–1471). Dispatch then gives the handler onlyoutcome.value, not the original request (protocol.ts lines 1733–1751). So once that method schema strips a sibling, an author schema cannot recover it.I would avoid fixing this only by making
CallToolRequestParamsSchemaglobally loose. That would change the typed payload delivered to every spec handler, implicitly bless non-standard top-level fields, and create collision behavior when a future protocol revision assigns one of those names. The base-schema comment is compatibility intent, not a claim thatresource_typeorrecord_idare normative;_metaremains the standard extension space.A bounded API could keep the validated spec request as argument 1 while exposing the untouched wire params through an explicit opt-in/context field, or add a handler-registration option that preserves unknown params. The key is that the preservation choice must happen before
validateRequest(...).valuereplaces the input.Suggested regression matrix for both maintained wire eras:
paramsroot;name,arguments,_meta, and era-specific task/input-response members retain their existing validation;Source review only; I did not run the TypeScript test suite. AI assistance was used to inspect the paths and draft this comment; I reviewed the cited source and conclusions before posting.
kesslerio commentedon Oct 14, 2026
Could a maintainer confirm which bounded API direction you would accept: an opt-in context field exposing original wire params, or a handler-registration option preserving unknown params before
validateRequestreplaces the input?The sketch above matches my downstream evidence on server/core 2.3.1. The installed dispatch path passes
validateRequest(method, request).valueto the handler. In my recorded raw-stdio tests, wrapped calls reach the handler with their business fields in both wire eras; loose calls in both eras loseresource_typeandrecord_idbefore application normalization and fail with missingresource_type. The HTTP backend was mocked, and these tests made no live service writes. I have not run the SDK's own test suite.One clarification to my original report: applying
.passthrough()to an author schema preserves fields insidearguments, but cannot recover siblings already stripped by the method request schema. This low-level path usesServer.setRequestHandler, with noMcpServer.registerToolschema to adjust.I favor keeping the validated request as the first handler argument and exposing original params separately through an explicit opt-in context field. The five-item regression matrix in the linked comment covers the behavior I need in both eras: pinned defaults, exact sentinel preservation, unchanged validation of known fields, author acceptance/rejection, and defined precedence for future-known-field collisions.
_metaremains the standard extension space; the loose business fields here are an existing downstream compatibility requirement.Would that approach fit the SDK, or would you prefer the registration option? I will wait for that decision before implementing against either API.
kesslerio commentedon Oct 15, 2026
I opened #3038 with an implementation of the opt-in raw-params direction. A note on what it covers and where the guarantee stops, since the thread discussed two options:
The PR captures
rawRequest.paramsafter transport parsing but before handler dispatch, and exposes it behind an opt-in flag. For my downstream case (unknown sibling fields besidenameandargumentsontools/call), this preserves them through validation. That was the failure I filed, and the PR's tests cover it.One boundary I found while testing: fields nested inside schema-parsed objects are normalized by transport schemas before capture. For example,
_metasub-fields run throughRelatedTaskMetadataSchema, so they are not preserved. Rather than extend capture across the transport boundary in this PR, I documented that limit in the doc comments, the changeset, and the PR description, and added a stdio-level test asserting the narrowed contract. If maintainers want pre-parse preservation as the guarantee, that deserves its own review of the transport layer's parse order; I am happy to take it up here or in a follow-up if that direction is preferred.The PR also carries a regression matrix covering both the legacy and current request-schema eras, since the original loose-params loss reproduced in both.
felixweinberger commentedon Oct 23, 2026
This is the current behavior, and v1 does the same: a handler for a spec method gets the request as the spec schema parses it. #3087 documents it. We asked in #3038 if the schema form of
setRequestHandlercovers this case.Generated by Claude Code