You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ServerCapabilities drops unknown keys, so a server can't advertise events for ChatGPT's MCP Events #3640
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:
Let ServerCapabilities keep extra keys (extra="allow"), so the "not a closed set" docstring holds and the sieve passes them through.
A supported way to add capability keys, like extensions already has.
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
importmcp_typesastcaps=t.ServerCapabilities(tools={}, events={})
print(caps.model_dump(by_alias=True, exclude_none=True))
# prints {'tools': {}} (the 'events' key is dropped)
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.
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.
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.
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.
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.
Activity
Ben-Home commentedon Oct 4, 2026
The docstring saying "not a closed set" while the model silently drops unknown keys is the bug in one line, and the
_serializesieving 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
ServerCapabilitieslocally (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:
ServerCapabilitiesshould model the known keys and permit extras, and_serializeshould pass through keys it does not recognize instead of sieving to the schema. Once both are true, a server can advertise an extension likeeventswith 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
eventsexplicitly 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.
epistemedeus commentedon Oct 4, 2026
Reproduced on main
2118f14fwith mcp/mcp-types 2.3.0. The public capability models discard unknown keys, and the generated wire model discards them again throughServerRunner._serialize→serialize_server_result.I prepared a fork candidate that preserves unknown keys only on
ClientCapabilitiesandServerCapabilities, including their generated schema-era models. Known fields still validate; nested known capability objects stay closed. The wire sieve still removes cross-erataskson 2026-07-28 andextensionson older versions. This is not a hardcodedeventsfield.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.s1980amber-commits commentedon Oct 4, 2026
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
MCPServerwith the library installed, our middleware removed, andeventsadded through the low-level server'sget_capabilities, the same way as your test. Checked with an actualserver/discovercall over Streamable HTTP (2026-07-28).prompts,resources,tools. Noevents.events,prompts,resources,tools.eventskey, and all 33 of our tests pass, including the HTTP ones.So once something like this lands, servers can advertise
eventswithout any middleware, and I'll switch the library over to theget_capabilitiesroute. It would be good to see it upstream.epistemedeus commentedon Oct 4, 2026
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 unpatchedget_capabilitiespath works.The patched no-middleware path advertises
events: {"listChanged": false}over Streamable HTTP. With both enabled, your middleware'scapabilities["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.
s1980amber-commits commentedon Oct 4, 2026
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_capabilitiesand keep the middleware for older SDK versions.