Repository navigation
Omit an unset experimental capability from the initialize result - #3614
Conversation
`create_initialization_options()` turned a missing experimental map into
`{}`, so a server with nothing configured sent `"experimental": {}` on
`initialize` while its `server/discover` result left the field out. Pass
the argument through unchanged so both results omit it.
An experimental map the caller passes explicitly, including an empty
one, is still sent as given.
Fixes #3254
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked for in-tree readers of ServerCapabilities.experimental that assume a dict — there are none; the only .experimental consumer in src/ is check_client_capability in src/mcp/server/connection.py, which reads client capabilities and already guards None. The passthrough also matches get_capabilities()'s own None default, so the server/discover and initialize paths now share one code path for the unset case.
Extended reasoning...
One-line change in src/mcp/server/lowlevel/server.py removing the or {} coercion in create_initialization_options, plus three hand-trimmed inline snapshots and one new in-memory Client test; no security-sensitive surface. The inline findings cover the migration-doc drift and the v2 compatibility-contract question, which are maintainer decisions, so a human should weigh those; the ruled-out note records that no SDK code path depends on the old {} value.
| capabilities=self.get_capabilities( | ||
| notification_options or NotificationOptions(), | ||
| experimental_capabilities or {}, | ||
| experimental_capabilities, |
There was a problem hiding this comment.
🟡 nit (optional): Readers migrating from v1 are told the v2 initialize result differs only in server_version, which is no longer true after this change. docs/migration.md:1287 says of create_initialization_options() that "the only value that differs is server_version", but server.py:548 now also drops the v1 "experimental": {} from the legacy initialize result when nothing is configured. Fix: update that migration.md sentence (correcting an existing entry is allowed) to also note that an unconfigured server omits experimental instead of sending {}, so caps.experimental can be None on a legacy connection.
Why this was flagged
The PR removes experimental_capabilities or {} at src/mcp/server/lowlevel/server.py:548, so a lowlevel Server or MCPServer with no experimental capabilities now returns ServerCapabilities(experimental=None) from the initialize handshake; on the base branch and in v1 it sent "experimental": {}. docs/migration.md:1287 still states that create_initialization_options() builds the same InitializationOptions as v1 and "the only value that differs is server_version". A v1 user following that page and keeping code like caps.experimental.get(...) on a legacy connection now gets AttributeError on None, and the migration doc does not warn them. AGENTS.md requires docs/ to be updated in the same PR when user-visible behaviour changes, and permits correcting existing migration.md entries; no docs file is touched in this diff.
Verification: nit. Triggering condition: any v1 user reading the migration page for an unconfigured lowlevel Server. Mechanism: the diff changes src/mcp/server/lowlevel/server.py:548 so an unconfigured server now builds experimental=None where the base built experimental={}. docs/migration.md:1287 still reads "the only value that differs is server_version", so that sentence is now inaccurate. The diff touches no file under docs/.
| capabilities=self.get_capabilities( | ||
| notification_options or NotificationOptions(), | ||
| experimental_capabilities or {}, | ||
| experimental_capabilities, |
There was a problem hiding this comment.
🟡 nit (optional): AGENTS.md says a change to a released v2 API's observable behaviour is a maintainer design decision that should generally be avoided: dropping or {} here makes create_initialization_options() (and so every legacy initialize result from Server/MCPServer) omit experimental instead of sending {}, which v1 and 2.x have always emitted. Fix: either keep the {} coercion on the legacy path (and let server/discover stay as-is), or have a maintainer explicitly sign off on the wire change and record it where 2.x behaviour changes are noted.
Why this was flagged
Nothing fails in the SDK itself; the guard is the 2.x compatibility contract. A client on a legacy (<=2025-11-25) connection that reads caps.experimental.get(...) or caps.experimental[...] without a None check will now raise AttributeError/TypeError where it previously got an empty dict. Mitigating facts the maintainer may weigh: the field has always been typed dict | None; server/discover already omitted it, so modern connections returned None already; docs/client/index.md already states an absent capability is None; the PR description lists this as a deliberate legacy-path change and cites other SDKs omitting it; the author is a listed project author in pyproject.toml. An explicit experimental_capabilities={} passed by a caller is still sent as {}.
Verification: AGENTS.md (base 17aaf25, "Branching Model") states: "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a bullet under "Known limits" in the Streamable HTTP section, because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): No Tool Search transform is used. The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: No Code Mode in this repo; no `CodeMode(` or `max_duration_secs`. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. - `src/mcp_server_kalshi/server.py:172-176,1250` builds `experimental_capabilities={}` for the low-level stdio path. Under mcp 2.3.0 ([#3614](modelcontextprotocol/python-sdk#3614)) an empty `experimental` is left out of `initialize`. No test or client here reads it; the protocol and conformance runs pass. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--all-extras`, 639 passed, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `KALSHI_MCP_AUTH_TOKEN` and `KALSHI_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `black --check src tests`: clean - `mypy`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_openapi_drift.py`: passed - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
Fixes #3254.
A server with no experimental capabilities configured sent
"experimental": {}in itsinitializeresult but left the field out of itsserver/discoverresult, so the same server looked different depending on how the client connected. This makesinitializeomit it too.What changes on the wire
initializeresult no longer carries"experimental": {}.Serverand toMCPServer, on every transport.server/discoveris unchanged.create_initialization_options(experimental_capabilities=...)is still sent as given, including an explicit{}.Why
server/discoveralready omits it, and the docs already show capabilities without it.Who could notice
capabilities.experimentalfrom a legacy connection without handlingNone, for examplecaps.experimental.get(...).Nonehere.caps.experimental or {}works on both.What this does not do
initialize, not onserver/discover. That needs the map to live on the server rather than in per-run options, which is part of Capabilities API + server/discover handler #2896.The change
create_initialization_options()passedexperimental_capabilities or {}toget_capabilities(); it now passes the argument through unchanged.get_capabilities()is untouched.How it was checked
tests/server/lowlevel/test_server_discover.py: a bare server connected once throughinitializeand once throughserver/discoverreports noexperimentaleither way.experimental={}on the legacy path (tests/client/test_client.py,tests/interaction/lowlevel/test_initialize.py).mainand pass with the change../scripts/testpasses with 100% coverage; ruff and pyright are clean.AI Disclaimer