Skip to content

ServerCapabilities drops unknown keys, so a server can't advertise events for ChatGPT's MCP Events #3640

Open

Description

@s1980amber-commits

Initial Checks

Release line

2.x (current stable)

Description

What happens. ServerCapabilities is documented as "Not a closed set", but unknown keys are silently dropped (see the example below). Spec results are also sieved against the schema in ServerRunner._serialize, so even a custom server/discover handler that returns a dict with capabilities.events loses it before it reaches the wire.

What I expected. Extra capability keys to be kept, as the docstring suggests.

Why it matters. ChatGPT's MCP Events (launched 29 September 2026, docs at https://developers.openai.com/plugins/build/mcp-events) only subscribes to servers whose server/discover result contains top-level capabilities.events. With the SDK as it is, a Python server can't advertise it without working around the SDK.

Workaround. The events/* methods themselves register fine with add_request_handler. The capability can be added by a ServerMiddleware that edits the serialised result after call_next. That works, and I've tested it end to end with ChatGPT, but it relies on internals.

Possible fixes, in rough order of effort:

  1. Let ServerCapabilities keep extra keys (extra="allow"), so the "not a closed set" docstring holds and the sieve passes them through.
  2. A supported way to add capability keys, like extensions already has.
  3. First class Events support, following the draft in modelcontextprotocol/experimental-ext-triggers-events.

For anyone who needs it before then, I've published the workaround plus the full Events implementation (verification, signing, retries, persistence) as mcp-webhook-events on PyPI, MIT licensed: https://github.com/s1980amber-commits/mcp-webhook-events

Example Code

import mcp_types as t

caps = t.ServerCapabilities(tools={}, events={})
print(caps.model_dump(by_alias=True, exclude_none=True))
# prints {'tools': {}}  (the 'events' key is dropped)

Python & MCP Python SDK

Python 3.12.3 (Linux) and 3.14.4 (Windows)
mcp 2.2.0, mcp-types 2.2.0
Protocol 2026-07-28, Streamable HTTP, stateless

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Oct 3, 2026
  2. Ben-Home commented on Oct 4, 2026

    @Ben-Home

    The docstring saying "not a closed set" while the model silently drops unknown keys is the bug in one line, and the _serialize sieving makes it worse because even a correct handler result loses the extra capability before it reaches the wire. Fixing one without the other will leave the second failure mode in place.

    The workaround you found is the right shape but it should not be necessary. Editing the serialized result in middleware means the capability is present on the wire while the server's own type model says it is not, so anything that introspects ServerCapabilities locally (health checks, capability reflection, tests) will disagree with what the client sees. That divergence is the thing to eliminate, not just the symptom.

    The fix is a single behavioral decision applied in two places: ServerCapabilities should model the known keys and permit extras, and _serialize should pass through keys it does not recognize instead of sieving to the schema. Once both are true, a server can advertise an extension like events with a typed field for the known keys and an open tail for the rest, and local introspection matches the wire.

    On the ordering you listed: option 1 is the right default and it is also the smallest change, because "not a closed set" is already the documented contract, so the implementation is the thing that is wrong. Typing events explicitly is a good second step, but it should not gate the pass-through, otherwise every future extension re-opens the same gap.

    The reason this is worth fixing even though a workaround exists: an extension protocol only works if a server can advertise it without patching internals. If advertising requires middleware, then only servers willing to reach into SDK internals can support the extension, and the ecosystem fragments by implementation detail rather than by capability.

    The pattern generalizes to any extension mechanism: keep the known fields typed, keep the container open, and let the negotiated capability set decide behavior. We ended up on that model wiring multiple upstream APIs behind one MCP server (corpusiq.io) precisely because new capabilities kept arriving faster than the schema could type them.

  3. epistemedeus commented on Oct 4, 2026

    @epistemedeus

    Reproduced on main 2118f14f with mcp/mcp-types 2.3.0. The public capability models discard unknown keys, and the generated wire model discards them again through ServerRunner._serialize → serialize_server_result.

    I prepared a fork candidate that preserves unknown keys only on ClientCapabilities and ServerCapabilities, including their generated schema-era models. Known fields still validate; nested known capability objects stay closed. The wire sieve still removes cross-era tasks on 2026-07-28 and extensions on older versions. This is not a hardcoded events field.

    The regression checks actual Streamable HTTP discover bytes, modern and legacy SDK consumers, another unknown capability key, malformed known fields and an ordinary tools call. Independent remote replay passes the full pytest suite and the strict 100% coverage gate, plus the edited-test Ruff/Pyright checks.

    This is a fork patch, not an upstream release or complete Events implementation. It does not register events/* methods, verify callbacks or deliver subscriptions; your middleware remains the workaround for installed releases without this fix. Prepared with AI assistance and independently reviewed/tested. No new PR is being opened or the automatically closed draft reopened.

  4. s1980amber-commits commented on Oct 4, 2026

    @s1980amber-commits
    Author

    Thanks for this. I tested it against mcp-webhook-events, which currently relies on the middleware workaround.

    Setup: mcp and mcp-types 2.3.0 from PyPI, with the mcp-types part of your change applied (it applies cleanly to the 2.3.0 release). A real MCPServer with the library installed, our middleware removed, and events added through the low-level server's get_capabilities, the same way as your test. Checked with an actual server/discover call over Streamable HTTP (2026-07-28).

    • Stock 2.3.0: capabilities come back as prompts, resources, tools. No events.
    • 2.3.0 with your fix: events, prompts, resources, tools.
    • With your fix and our middleware still in place: no clash, a single events key, and all 33 of our tests pass, including the HTTP ones.

    So once something like this lands, servers can advertise events without any middleware, and I'll switch the library over to the get_capabilities route. It would be good to see it upstream.

  5. epistemedeus commented on Oct 4, 2026

    @epistemedeus

    Thanks, I independently replayed the published 2.3.0 wheels with the four-file types patch and your library at 2a5a005. All 33 library tests pass with both stock and patched packages. The stock pass uses your existing middleware; it does not mean the unpatched get_capabilities path works.

    The patched no-middleware path advertises events: {"listChanged": false} over Streamable HTTP. With both enabled, your middleware's capabilities["events"] = {} wins after serialization, without a duplicate key. A second custom capability, including a nested null, survives too. Malformed known fields still fail validation and ordinary tools calls work.

    This second replay used Python 3.14.8/Pydantic 2.13.5; the earlier replay used Python 3.12.3. The installed runner is unchanged. The types-only fork change remains a release-specific compatibility patch, not an upstream release or an Events method implementation. Prepared with AI assistance and independently replayed.

  6. s1980amber-commits commented on Oct 4, 2026

    @s1980amber-commits
    Author

    Thanks for taking the time to replay all that, it's really appreciated. Good to know the patched route works without the middleware and that the two don't clash. If the change lands upstream I'll switch the library over to get_capabilities and keep the middleware for older SDK versions.

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

    bugSomething isn't workingspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions