Skip to content

Low-level Server.setRequestHandler strips unknown tools/call params members before any author schema can preserve them #3008

Open

Description

@kesslerio

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

  1. jarrettdustinqq commented on Oct 13, 2026

    @jarrettdustinqq

    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.params as open for unofficial extensions (2025 lines 65–69, 2026 lines 184–188). The SDK mirrors that at the generic boundary with BaseRequestParamsSchema.loose() (schemas.ts lines 50–77), but CallToolRequestSchema replaces params with the non-loose CallToolRequestParamsSchema (lines 1454–1471). Dispatch then gives the handler only outcome.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 CallToolRequestParamsSchema globally 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 that resource_type or record_id are normative; _meta remains 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(...).value replaces the input.

    Suggested regression matrix for both maintained wire eras:

    • default spec handler behavior remains explicitly pinned;
    • opt-in/raw path sees an exact sentinel sibling at params root;
    • name, arguments, _meta, and era-specific task/input-response members retain their existing validation;
    • an author schema may accept or reject the preserved sibling;
    • a future-known-field collision has a defined precedence rather than being silently shadowed.

    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.

  2. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 13, 2026
  3. kesslerio commented on Oct 14, 2026

    @kesslerio
    Author

    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 validateRequest replaces the input?

    The sketch above matches my downstream evidence on server/core 2.3.1. The installed dispatch path passes validateRequest(method, request).value to 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 lose resource_type and record_id before application normalization and fail with missing resource_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 inside arguments, but cannot recover siblings already stripped by the method request schema. This low-level path uses Server.setRequestHandler, with no McpServer.registerTool schema 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. _meta remains 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.

  4. kesslerio commented on Oct 15, 2026

    @kesslerio
    Author

    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.params after transport parsing but before handler dispatch, and exposes it behind an opt-in flag. For my downstream case (unknown sibling fields beside name and arguments on tools/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, _meta sub-fields run through RelatedTaskMetadataSchema, 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions