Repository navigation
Description
Describe the bug
rmcp 3.9.0 silently drops unknown top-level fields when it deserializes ServerCapabilities. An MCP gateway that reads and forwards capabilities can therefore lose fields advertised by the upstream server. For example, ChatGPT looks for capabilities.events in initialize or server/discover responses to discover MCP Events. MCP Events documentation
To reproduce
Create a Cargo binary and add these dependencies to Cargo.toml:
[dependencies]
rmcp = { version = "=3.9.0", default-features = false, features = ["server"] }
serde_json = "1"Put this in src/main.rs and run cargo run:
use rmcp::model::ServerCapabilities;
use serde_json::json;
fn main() {
let input = json!({"tools": {}, "events": {}});
let capabilities: ServerCapabilities = serde_json::from_value(input.clone()).unwrap();
let output = serde_json::to_value(capabilities).unwrap();
println!("input: {input}");
println!("output: {output}");
}Expected behavior
Both tools and events should survive the JSON round trip. The MCP schema permits additional server capability fields. MCP ServerCapabilities schema
Logs
Actual output with the published rmcp 3.9.0 crate:
input: {"events":{},"tools":{}}
output: {"tools":{}}
Additional context
We carry a local #[serde(flatten)] patch to preserve additional fields. An earlier Rust SDK PR proposed the same capability behavior, and the Python SDK has a parallel report. Rust SDK PR #1170 · Python SDK issue #3640
AI assistance: I used Codex to search for prior reports, inspect the SDK source, write and run the minimal reproduction, and draft this issue.
Activity
github-actions commentedon Nov 12, 2026
@jokemanfire is on triage rotation this week and is the reviewer for this one.
Codex labeled this bug / P1 (T-model). Please confirm or correct those labels.
Automated triage. Labels are a first pass and the assigned maintainer has the final say.
jokemanfire commentedon Nov 12, 2026
Reproduced on current
main(f852ec0), and the previous attempt at fixing this is worth knowing about before anyone writes a patch.Repro (main,
f852ec0)Your program, run as a test in
crates/rmcp:ServerCapabilities(crates/rmcp/src/model/capabilities.rs:220-244) has nodeny_unknown_fieldsand no catch-all field, so serde silently discardseventson decode and can never emit it again.ClientCapabilities(:171-196) has the same shape.One thing that looks like it should cover this but does not: the
extensionsfield (SEP-1724,:227-232,ExtensionCapabilities = BTreeMap<String, JsonObject>at:33) only holds entries insideextensions. A top-leveleventskey is a different slot, so a gateway that decodes and re-encodes still loses it.Prior art
PR #1170 (muxammadreza) implemented exactly this — flatten-preserve for both capability structs plus extension-owned
SubscriptionFilterfields. It was closed by the stale bot on 2026-10-28, not rejected: the only comments are the 60-day and 14-day stale notices. So the approach has neither an approval nor a "no" from a maintainer.Options
A.
#[serde(flatten)] extra: JsonObjectonServerCapabilitiesandClientCapabilities.There is precedent for flatten on
JsonSchema-derived types in this crate:model.rs:566(JsonRpcRequest<R>),model.rs:4495(CreateMessageResult). UseBTreeMap<String, Value>so key order is deterministic, and#[serde(skip_serializing_if = "BTreeMap::is_empty")].Costs, in order of annoyance:
builder!from the same field list (capabilities.rs:385-399,:480-495), so this needs askipin that macro or every caller grows a pointlessenable_extra();PartialEq/Debugnow include the extras — harmless, but visible in test assertions;"tooLs": {}survives as opaque data. For an open set that is arguably correct, but it does hide client bugs;schemarswill emit a merged object withadditionalPropertiesinstead of the current closed shape, so generated bindings get looser.Semver: both structs are
#[non_exhaustive](:223,:177), so an added field is a minor change, not a break.B. Typed
events: Option<JsonObject>.Six lines, fixes today's field, zero risk. But the schema treats capabilities as an open set, so the next extension repeats this issue.
C. No model change; gateways forward the raw
initialize/discoverresult.Decode→re-encode is what loses the data, so keeping the raw JSON is a real fix with no API risk. It does not help typed access to the new field, and it puts the burden on every intermediary to remember.
D. Private catch-all via a hand-written
Deserialize+ accessor.Same effect as A without exposing the map in
PartialEq; more code, and it makesJsonSchemageneration awkward. Only worth it if A's equality/Debugchange is a problem.I would pick A, with a round-trip test (the repro above) and a collision test asserting an unknown key never overwrites a known one.
Unknown on my side: whether maintainers intend the typed capability structs to stay closed and handle unknown fields only at the gateway layer. Nothing in the repo states a position; PR #1170 died before anyone took one. Happy to send the A patch if that is the direction — say which option you want and I will implement it against
main.kensj commentedon Nov 12, 2026
Thanks for reproducing this on
main. I support option A. Preserving unknown top-level fields letscapabilities.eventssurvive the JSON round trip and handles future capability fields the same way.I have the
ServerCapabilitiespart working locally and would be happy to put together a PR for option A, including the round-trip and collision tests you suggested. If you’d prefer to write the PR, I can test it on my platform and check whethercapabilities.eventssurvives discovery end to end.