Skip to content

ServerCapabilities drops unknown fields such as events #1447

Open

Description

@kensj

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

  1. added
    bugSomething is not working
    P1High: significant functionality gap or spec violation
    T-modelModel/data structure changes
    ready for workIssue is well-defined and ready to be picked up
    on Nov 12, 2026
  2. github-actions commented on Nov 12, 2026

    @github-actions
    Contributor

    @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.

  3. jokemanfire commented on Nov 12, 2026

    @jokemanfire
    Member

    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:

    input:  {"events":{},"tools":{}}
    output: {"tools":{}}
    assert_eq!(output, input)  ->  left: {"tools":{}}  right: {"events":{},"tools":{}}
    

    ServerCapabilities (crates/rmcp/src/model/capabilities.rs:220-244) has no deny_unknown_fields and no catch-all field, so serde silently discards events on 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 extensions field (SEP-1724, :227-232, ExtensionCapabilities = BTreeMap<String, JsonObject> at :33) only holds entries inside extensions. A top-level events key 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 SubscriptionFilter fields. 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: JsonObject on ServerCapabilities and ClientCapabilities.
    There is precedent for flatten on JsonSchema-derived types in this crate: model.rs:566 (JsonRpcRequest<R>), model.rs:4495 (CreateMessageResult). Use BTreeMap<String, Value> so key order is deterministic, and #[serde(skip_serializing_if = "BTreeMap::is_empty")].
    Costs, in order of annoyance:

    • the builders are generated by builder! from the same field list (capabilities.rs:385-399, :480-495), so this needs a skip in that macro or every caller grows a pointless enable_extra();
    • PartialEq/Debug now include the extras — harmless, but visible in test assertions;
    • typos stop being dropped silently: "tooLs": {} survives as opaque data. For an open set that is arguably correct, but it does hide client bugs;
    • schemars will emit a merged object with additionalProperties instead 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/discover result.
    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 makes JsonSchema generation awkward. Only worth it if A's equality/Debug change 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.

  4. kensj commented on Nov 12, 2026

    @kensj
    Author

    Thanks for reproducing this on main. I support option A. Preserving unknown top-level fields lets capabilities.events survive the JSON round trip and handles future capability fields the same way.

    I have the ServerCapabilities part 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 whether capabilities.events survives discovery end to end.

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

Metadata

Metadata

Assignees

Labels

P1High: significant functionality gap or spec violationT-modelModel/data structure changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions