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.
Fixes #1347.
A request that names a method the SDK defines but whose
paramsdo not deserialize falls through the untaggedClientRequesttoCustomRequest, and the defaulton_custom_requestanswers-32601with the method name. The client is told the server has notools/callwhen the problem is its own arguments.Change: before calling
on_custom_request, the server checks whether the method is oneClientRequestdefines (ClientRequest::known_method_params_error, crate-private). If it is, it reads the request again as that method's type to recover the deserialization error and answers-32602with it, for exampleinvalid params for tools/call: invalid type: integer7, expected a string. A method the SDK does not define still reacheson_custom_requestunchanged, and so do the methods this lifecycle does not serve, which keep-32601as their typed arms answer them (SEP-2575):initialize,ping,logging/setLevel,resources/subscribeandresources/unsubscribeon 2026-07-28,subscriptions/listenon the legacy lifecycle.Test:
test_known_method_with_invalid_params_is_invalid_paramssends the rows from the issue (tools/callwith a numeric name, with string arguments, without params;prompts/getandresources/readwith a numeric field) over a duplex transport and checks each gets-32602naming the method, and that none reacheson_custom_request. Without the change it fails on the first row.cargo clippy(all three CI variants) andcargo test -p rmcppass with the CI feature sets, and the conformance server suites pass locally (2025-11-25: 83/83, 2026-07-28: 153/153, includingserver-stateless).Not in this PR: the smaller point in the issue, a non-object
paramsanswered-32600without the request'sid. Happy to open that separately.AI disclosure: written with Claude Code; I reviewed the change and ran the tests above.
🤖 Generated with Claude Code