Skip to content

fix(server): normalize experimental capability to {} in get_capabilities - #3255

Open
Hamjaster wants to merge 1 commit into
modelcontextprotocol:mainfrom
Hamjaster:fix-experimental-capability-none-vs-empty-dict
Open

fix(server): normalize experimental capability to {} in get_capabilities#3255
Hamjaster wants to merge 1 commit into
modelcontextprotocol:mainfrom
Hamjaster:fix-experimental-capability-none-vs-empty-dict

Conversation

@Hamjaster

Copy link
Copy Markdown

Fixes #3254

What's broken

An unconfigured Server reports its experimental capability differently depending on which discovery path answers:

  • initialize (via create_initialization_options()): experimental == {}, field present on the wire.
  • server/discover (calls get_capabilities() directly): experimental is None, field omitted from the wire dump.

That's client-visible: .get(...) works on the legacy value and raises on the modern one, and is not None checks flip meaning depending on which path answered. Same server, same lack of configuration, two different answers.

Why

create_initialization_options() was the only caller that normalized a missing experimental_capabilities argument to {} before handing it to get_capabilities(). get_capabilities() itself just used its own parameter default (None) whenever a caller didn't do that normalization first — and server/discover's internal handler calls get_capabilities(protocol_version=ctx.protocol_version) with nothing for experimental_capabilities, so it fell straight through to None.

The fix

One line: move the normalization into get_capabilities() itself, right next to the existing notification_options = notification_options or NotificationOptions() fallback that already does the same job for a sibling parameter. Now every caller gets {} unless it explicitly passes something else, regardless of which path calls it.

How I checked it

Ran the exact repro from the issue against both trees:

# main
legacy {} True
modern None False

# this branch
legacy {} True
modern {} True

Added a test in tests/server/lowlevel/test_server_discover.py (the file that already exercises get_capabilities() via the discover path) asserting both create_initialization_options() and server/discover report experimental == {} for the same unconfigured server. It fails on main with assert None == {} and passes with this change.

ruff format/ruff check/pyright are all clean. Ran the full tests/server/ suite (1213 tests) and it passes. I didn't run the complete coverage/strict-no-cover gate across the whole repo, but confirmed the new line is exercised (not in coverage report's missing-lines list) when running the affected test files.

Type

🐛 Bug Fix

Disclosure

An AI coding agent helped me write this. I read the SDK's fallback pattern in get_capabilities(), picked the one-line fix that matches it, reproduced the issue's exact repro on both trees myself, and can walk through any part of this change.

An unconfigured server reported experimental differently depending on which
discovery path answered: {} via create_initialization_options() (the legacy
initialize path), None via a direct get_capabilities() call with no
experimental_capabilities argument (what server/discover does internally).

create_initialization_options() was the only caller normalizing None to {}
before passing it down, so get_capabilities() itself fell back to its own
parameter default whenever a caller didn't normalize first. Move the
normalization into get_capabilities(), next to the existing
notification_options fallback, so every caller gets the same value regardless
of what it passes.

Verified with the repro from the issue: legacy and modern now both report
experimental={} for the same server, and "experimental" is present in both
wire dumps instead of only the legacy one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] MCPServer reports empty experimental capabilities as {} via initialize but None via server/discover

1 participant