Repository navigation
Conversation
A request that names a method the SDK defines but whose params do not deserialize fell through the untagged ClientRequest to CustomRequest, and the default on_custom_request answered -32601 with the method name: the client was told the server has no tools/call when the problem was its own arguments. Before calling on_custom_request, the server now checks whether the method is one ClientRequest defines. If it is, it reads the request again as that method's type to recover the error and answers -32602 with it, for example "invalid params for tools/call: invalid type: integer `7`, expected a string". Unknown methods still reach on_custom_request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@connor4312 is on triage rotation this week and is the reviewer for this one. Labels come from the PR labeler. Please review the change. Automated triage. Labels are a first pass and the assigned maintainer has the final say. |
The conformance suite's server-stateless scenario (SEP-2575) expects initialize, logging/setLevel, resources/subscribe and resources/unsubscribe sent on the 2026-07-28 lifecycle to be answered -32601 with HTTP 404. Those requests reach CustomRequest too, so the previous commit turned them into -32602. The check now leaves alone the methods the typed arms already answer as not found for this lifecycle: those four plus ping on the 2026-07-28 lifecycle, and subscriptions/listen on the legacy one. known_method_params_error is compiled only with the server feature, so `cargo clippy --lib --no-default-features` no longer flags it unused. Conformance server suites pass locally: 2025-11-25 83/83, 2026-07-28 153/153. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| .on_custom_request(request, context) | ||
| .await | ||
| .map(ServerResult::CustomResult), | ||
| ClientRequest::CustomRequest(request) => { |
There was a problem hiding this comment.
I think this is identifying a valid problem but this seems like not the right approach. Having to maintain the method lists and mapping in multiple places is fragile and likely to drift over time. Fixing this however requires some rework up to the deserialization step where the distinction between unknown methods and incorrect params is lost.
Instead I would suggest reworking deserialization a little bit into something like this (rough example) where the method is identified in order to deserialize (or fail on) the specific request params
impl<'de> Deserialize<'de> for IncomingRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = RawRequest::deserialize(deserializer)?;
Ok(match raw.method.as_str() {
"tools/call" => {
Self::CallTool(serde_json::from_value(raw.params))
}
// ...
_ => Self::Custom(raw),
})
}
}
There was a problem hiding this comment.
Alternative design idea from Gemini, leaning on macros:
-
Derive the method name from the request type itself (no manual 18-entry table):
Every typed request is already parameterized byM: ConstString(Request<M, P>,RequestOptionalParam<M, P>,RequestNoParam<M>). Add a trait:pub(crate) trait RequestMethod { const METHOD: &'static str; } impl<M: ConstString, P> RequestMethod for Request<M, P> { const METHOD: &'static str = M::VALUE; } impl<M: ConstString, P> RequestMethod for RequestOptionalParam<M, P> { const METHOD: &'static str = M::VALUE; } impl<M: ConstString> RequestMethod for RequestNoParam<M> { const METHOD: &'static str = M::VALUE; }
-
In
ts_union!, dispatchDeserializeby"method"instead of#[serde(untagged)]:- Peek at
"method"in the buffered map/value. - Match
"method"against$(<$V as RequestMethod>::METHOD => ...)*:- Deserialize only
$V(which is also$O(1)$ instead of trying up to 18 untagged variants sequentially). - If
$V::deserializefails, capture the serde error right there (plus_metaintoextensions) into a non-public variant on the#[non_exhaustive]enum (e.g.,InvalidParams { method: &'static str, error: String, extensions: Extensions }) instead of falling through toCustomRequestand re-parsing later.
- Deserialize only
- Only if
"method"does not match any<$V as RequestMethod>::METHODdoes it deserialize asCustomRequest.
- Peek at
-
Move lifecycle/capability gating in
handle_requestinto a single pre-check onrequest.method()(not duplicated in both the match arms and anot_servedslice):
Beforematch request, run onevalidate_method_served(self, request.method(), legacy_request, &context)?check that handles:-
!legacy_request: rejectinitialize,ping,logging/setLevel,resources/subscribe,resources/unsubscribewith-32601(fixing the bug whereSetLevelRequestandInitializeRequestwith valid params currently bypass the!legacy_requestcheck). -
legacy_request: rejectsubscriptions/listenwith-32601. -
tasks/get|tasks/update|tasks/cancel: runvalidate_tasks_capability.
Then inmatch request, the individual arms don't needif !legacy_request/validate_tasks_capabilityat all, andClientRequest::InvalidParams { method, error, .. }simply returnsErr(McpError::invalid_params(format!("invalid params for {method}: {error}"), None)).
-
There was a problem hiding this comment.
Thanks, agreed — the second list was the weak part. I'll rework it along those lines in this PR:
- a
RequestMethodtrait so each request type carries its method name; - a
DeserializeforClientRequestthat dispatches onmethod, deserializes only that variant, and keeps its error in a#[doc(hidden)]InvalidParamsvariant (the enum is already#[non_exhaustive]), withCustomRequestonly for unknown methods; - one
validate_method_servedpre-check, which also closes the bypass you spotted forinitializeandlogging/setLevelwith valid params on 2026-07-28.
Should ServerRequest (sampling and elicitation on the client side) get the same treatment here, or in a follow-up?
AI disclosure: drafted with Claude Code; I reviewed it.
There was a problem hiding this comment.
Reworked along those lines in 201a516:
RequestMethodtrait: everyRequest*<M, _>carriesM::VALUE, so there is no hand-kept method table.ClientRequestnow deserializes bymethod(ts_union!grew a#[request]arm). The body is buffered once, only the named variant is parsed, and a serde error lands in a#[doc(hidden)]InvalidParams(InvalidParamsRequest)variant that keeps the method, params, error text and_meta.CustomRequestis only for methods the SDK does not define. SerializingInvalidParamsgives back the custom request it came from, so the client-side types are unaffected.- One
validate_method_servedpre-check inhandle_requestreplaces the per-armif !legacy_requestgates andvalidate_tasks_capabilitycalls. It also closes the bypass @SIDDARTHAREDDY8 spotted:initializeandlogging/setLevelwith well-formed params are now-32601on the 2026-07-28 lifecycle too.initializeis judged by whether the session is already inline (request metadata required, or a negotiated version ≥ 2026-07-28), since before the handshake the version is only known from its own params. - Before the handshake a malformed
initializeis answered-32602and the server keeps waiting for a proper one (service/server.rs).
ServerRequest is unchanged here; happy to give it the same shape in a follow-up if you want it on the client side as well.
Checks: fmt, the three CI clippy sets, cargo test -p rmcp with the CI feature set and with all features, conformance 2025-11-25 83/83 and 2026-07-28 153/153.
AI disclosure: written with Claude Code; I reviewed the change and ran the checks above.
SIDDARTHAREDDY8
left a comment
There was a problem hiding this comment.
Looked through the diff and the dispatch code in crates/rmcp/src/handler/server.rs at this head. I verified the check! list in known_method_params_error covers all 18 typed ClientRequest variants, so no known method is missed there. Two things I wanted to flag:
-
The
not_servedlist for the 2026-07-28 lifecycle includesinitializeandlogging/setLevel, but their typed arms further up in the same match do not have a lifecycle gate:InitializeRequestalways callsself.initialize(...), and theSetLevelRequestarm callsself.set_level(...)unconditionally, unlikePingRequest/SubscribeRequest/UnsubscribeRequest, which returnmethod_not_foundwhen!legacy_request. So on a 2026-07-28 connection a well-formedinitializeorsetLevel(for a handler that overridesset_level; the default returns method-not-found) is served, while a malformed one now gets -32601 instead of -32602. Is that asymmetry intended? If these methods are meant to be unserved in the new lifecycle, the typed arms would need the same gate; if they are served, they would arguably not belong innot_servedso bad params report -32602 liketools/calldoes. -
The new test in
crates/rmcp/tests/test_custom_request.rsonly exercises the default lifecycle. Thenot_servedbranch has no coverage: for example a malformedpingorresources/subscribeafter 2026-07-28 negotiation staying at -32601, and a malformedsubscriptions/listenon a legacy connection staying at -32601. That branching is the part most likely to drift as the SEP-2575 lifecycle rules evolve, so a case or two there would help.
One smaller question: in known_method_params_error, when the re-parse as the method's own type succeeds, the fallback message is "params do not match the method" and the real serde error is discarded. Is that path reachable in practice? I could not construct a case where the untagged dispatch fails but the single-method parse succeeds, so if it is defensive only, a short comment saying so would save the next reader the same detour.
Rework after review on modelcontextprotocol#1375. Instead of a second method table consulted before `on_custom_request`, the distinction is kept where it is lost: - `RequestMethod` trait: every `Request*<M, _>` carries `M::VALUE`. - `ClientRequest` deserializes by `method` (`ts_union!` `#[request]` arm): the body is buffered once, only the named variant is parsed, a serde error becomes the `#[doc(hidden)]` `InvalidParams` variant that keeps method, params, error and `_meta`; `CustomRequest` is only for methods the SDK does not define. Serializing `InvalidParams` yields the custom request it came from. - One `validate_method_served` pre-check in `handle_request` replaces the per-arm lifecycle gates and `validate_tasks_capability` calls, and also refuses `initialize` and `logging/setLevel` with well-formed params on the 2026-07-28 lifecycle. - Before the handshake a malformed `initialize` is answered -32602 and the server keeps waiting. Tests: deserialization by method, inline-lifecycle and legacy-lifecycle cases (removed methods stay -32601 with fitting and unfitting params), malformed initialize before the handshake, and the modelcontextprotocol#1347 rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@SIDDARTHAREDDY8 Thanks for the close read. All three are addressed in the rework (201a516):
AI disclosure: drafted with Claude Code; I reviewed it. |
…e parameter The hand-written `Deserialize` named its type parameter `D`, where `#[derive(Deserialize)]` had named it `__D`. The signature is the same, but the Public API Check compares the rendered item, so it read as a changed public item and the job failed on a minor release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
figitaki
left a comment
There was a problem hiding this comment.
Community review. connor4312 has the final call. The method-based dispatch reads well, and the RequestMethod trait fixes the drift concern from the first round. I wrote tests for a few edge cases, and three things came out of it. All three are reproduced; details are inline.
- The pre-init
InvalidParamsarm catches every method, not justinitialize. So malformed first requests skip the 2026-07-28 version check and the method-served check. (blocking, in my view) - After a rebase onto
main,skills/getandresources/directory/readanswer malformed params with-32602instead of-32601when the server doesn't declare the extension ordirectoryRead. Their capability checks (#1392, #1409) run inside the match arms, aftervalidate_method_served. (blocking once rebased)skills/listis fine: malformed cursors still get-32601. inline_lifecycle_tells_removed_methods_from_invalid_paramsnever finishes. It waits onserver_taskafterdiscover, whileserve()is still waiting for the next request. (blocking)
Results, all with the CI feature set: on main, on this PR, on this PR plus the guard from comment 1, and on a rebased branch with both fixes, the full suite has zero failures when the hanging test is skipped. Run alone, the hanging test times out after 300s on this PR, with or without the guard. It doesn't exist on main.
Non-blocking: servers that used to see malformed known-method requests in on_custom_request won't anymore. That's the correct behavior, but it's observable, so a changelog line would help (VERSIONING.md).
On ServerRequest, +1 to a follow-up. I'm happy to take that PR once this lands, unless you want it.
If it's useful, I can send the regression tests and both fixes as separate patches (tests separate from fixes). I'm not opening a competing PR.
AI disclosure: I used Claude Code to review the diff and draft these comments, and a coding agent to write the tests and run the suites. I reviewed the code, the test output and the response bodies myself.
| let id = req.id; | ||
| let mut request = match req.request { | ||
| ClientRequest::InitializeRequest(request) => break (request, id), | ||
| ClientRequest::InvalidParams(invalid) => { |
There was a problem hiding this comment.
This arm answers -32602 for any method before the handshake, not just initialize. On main, a first request that isn't initialize goes through the _meta check and then the inline path, where handle_request checks the protocol version and calls validate_method_served. With this arm, a malformed first request skips both:
| First request | main |
this PR | PR + guard |
|---|---|---|---|
malformed params, unsupported protocolVersion in _meta |
-32022 |
-32602 |
-32022 |
malformed logging/setLevel ({"level": 7}), 2026-07-28 |
— | -32602 |
-32601 |
well-formed logging/setLevel (control), 2026-07-28 |
-32601 |
-32601 |
-32601 |
The guard only intercepts initialize, and lets any other InvalidParams fall through to request => request:
ClientRequest::InvalidParams(invalid) if invalid.method() == "initialize" => { /* existing -32602 + continue */ }malformed_initialize_before_handshake_is_invalid_params still passes with the guard.
| /// its own params supply the fallback protocol version while the handshake | ||
| /// is still open, so only a session already on the inline lifecycle, or a | ||
| /// request that declares 2026-07-28 in its `_meta`, has no `initialize`. | ||
| fn validate_method_served<H: ServerHandler>( |
There was a problem hiding this comment.
Heads-up for the rebase: main now gates skills/get / skills/list and resources/directory/read inside their match arms with validate_skills_capability. Those arms run after this pre-check. On a rebased branch, with a server that doesn't declare the skills extension (or directoryRead):
skills/getwith malformed params →-32602(expected-32601)resources/directory/readwith malformed params →-32602(expected-32601)
Moving the skills and directoryRead checks in here, next to the tasks check, gives -32601 for both, and regression tests for both pass with it. skills/list isn't affected: a bad cursor object still gets -32601.
Every capability-gated method added later will need the same treatment. A table-driven test would catch the next one: for each gated method, turn its capability off, send malformed params, and assert -32601.
| client.receive().await, | ||
| Some(ServerJsonRpcMessage::Response(_)) | ||
| )); | ||
| let running = server_task.await.expect("server task"); |
There was a problem hiding this comment.
This test doesn't finish. It sends discover and then awaits server_task, but after answering a discover request serve() goes back to waiting for the next message (the bootstrap-probe branch in serve_server_with_ct_inner continues the loop), so the await never returns. Run alone, it times out at 300s on this branch, with or without the guard. Awaiting server_task only after a request that selects the lifecycle, or dropping the discover step, should let it finish. (blocking: it hangs a CI run that includes it)
Fixes #1347.
A request that names a method the SDK defines but whose
paramsdo not deserialize used to fall through the untaggedClientRequesttoCustomRequest, and the defaulton_custom_requestanswered-32601with the method name. The client was told the server has notools/callwhen the problem was its own arguments.Change (reworked after review, see the thread below):
RequestMethodtrait: everyRequest*<M, _>carries its method name (M::VALUE).ClientRequestdeserializes bymethodinstead of#[serde(untagged)]: the body is buffered once, only the named variant is parsed, and a serde error becomes the#[doc(hidden)]InvalidParams(InvalidParamsRequest)variant, which keeps the method, params, error text and_meta.CustomRequestis only for methods the SDK does not define. SerializingInvalidParamsyields the custom request it came from.validate_method_servedpre-check inhandle_requestreplaces the per-arm lifecycle gates andvalidate_tasks_capabilitycalls: methods the lifecycle does not serve stay-32601whatever their params (ping,logging/setLevel,resources/subscribe,resources/unsubscribeon 2026-07-28,subscriptions/listenon the legacy lifecycle,initializeonce a session is inline). This also closes the bypass whereinitializeandlogging/setLevelwith well-formed params were served on 2026-07-28.ClientRequest::InvalidParamswith-32602naming the method, for exampleinvalid params for tools/call: invalid type: integer7, expected a string. Before the handshake a malformedinitializegets the same answer and the server keeps waiting.Tests:
test_deserialization::client_request_by_method(fitting params → typed variant, unfitting →InvalidParamskeeping_meta, missing params, unknown method →CustomRequest);test_known_method_with_invalid_params_is_invalid_params(the rows from the issue, none reacheson_custom_request);inline_lifecycle_tells_removed_methods_from_invalid_paramsandtest_legacy_lifecycle_keeps_unserved_method_not_found(removed methods stay-32601on each lifecycle, served ones give-32602);malformed_initialize_before_handshake_is_invalid_params. fmt, the three CI clippy sets andcargo test -p rmcppass with the CI feature set and with all features; conformance server suites pass locally (2025-11-25: 83/83, 2026-07-28: 153/153).Not in this PR: a non-object
paramsanswered-32600without the request'sid(the smaller point in the issue), and the same deserialization shape forServerRequest. Happy to open either separately.AI disclosure: written with Claude Code; I reviewed the change and ran the tests above.
🤖 Generated with Claude Code