refactor(mcp): migrate from MCP SDK v1 to v2.0.0b1#112
Conversation
Breaking changes applied: - streamablehttp_client → streamable_http_client (renamed import) - McpError → MCPError (exception renamed) - mcp.types → mcp_types (split into separate package) - FastMCP → MCPServer (base class renamed) - SSLCapableFastMCP → SSLCapableMCPServer - MCPServer.__init__ no longer accepts host/port/transport_security; these are passed to run methods and streamable_http_app() instead - streamable_http_client returns 2-tuple (read, write) instead of 3-tuple; session ID tracking and manual termination removed (terminate_on_close=True) - Remove _http_client_factory; use pre-built httpx.AsyncClient directly - Replace self.settings.host/port with self.server_config.host/port Deprecations removed: - __terminate_http_session(), _session_id, _get_session_id, _http_client_factory Tests: 190 passing
- Fix spurious whitespace in runtime.py f-strings and dict values - Replace broken server.settings.host/port doctests with server.server_config.* - Simplify getattr(_transport_security) to direct attribute access - Update stale "Fast MCP server" docstring and class Examples indentation - Update scaffold template to mcp==2.0.0b1 and mcp-types==2.0.0b1 - Remove unused ErrorData import in test_client_reconnect.py - Remove dead plugin._session_id = None test setup - Rename http_client_instance to http_client Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
…d http client - runtime.py: pass host=server_config.host to streamable_http_app(). Without it the v2 SDK defaults host to 127.0.0.1 and auto-applies a localhost-only DNS rebinding allowlist when transport_security is None, causing HTTP 421 on every non-localhost request for servers bound to 0.0.0.0. - client.py: enter the caller-provided httpx.AsyncClient into the exit stack. The v2 SDK does not close a caller-provided client, so it leaked on every connection attempt. Entered before the transport so LIFO teardown fires the terminate_on_close DELETE while the client is still open. - test_runtime_coverage.py: set _transport_security on object.__new__ servers that bypass __init__, now required after dropping the getattr fallback. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
- Split test_invoke_hook_reconnects_on_session_terminated: the session-terminated success path (reconnect then retry succeeds) was dead code shadowed by a second plugin setup. Restore it as a real assertion and re-add the separate no-reconnect-on-other-errors test that had been merged away. - Assert streamable_http_client is called with a pre-built http_client instance and terminate_on_close=True (the v2 API change and DELETE-on-close behavior that replaced the removed __terminate_http_session). - Assert UDS servers populate _transport_security with DNS rebinding protection, non-UDS servers leave it unset, and run_streamable_http_async forwards both transport_security and the real bind host to streamable_http_app(). Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
There was a problem hiding this comment.
Good migration overall! Reviewed the v2 migration and pushed fixes for everything actionable. Two real bugs found (both fixed), plus a batch of cleanups and test gaps.
Fixes pushed
Bugs
runtime.py— HTTP 421 for non-localhost servers.streamable_http_app()was called withouthost=, so the v2 SDK defaulted tohost="127.0.0.1"and auto-applied a localhost-only DNS rebinding allowlist whenevertransport_securitywasNone. Any server bound to0.0.0.0would have rejected every non-localhost request with HTTP 421. Now passeshost=self.server_config.host. Verified against the real SDK: withhost="0.0.0.0"no allowlist is applied; localhost binds stay protected.client.py— httpx client leak. The v2 SDK does not close a caller-providedhttp_client(streamable_http.py:567skips it whenclient_provided=True), so the client created each connection attempt leaked. Now entered into the exit stack right after creation — before the transport — so LIFO teardown fires theterminate_on_closeDELETE while the client is still open, then closes the client. No leak on success or failure.
Cleanups:
- Fixed spurious find-replace whitespace in 6
runtime.pyf-strings/dict values. - Corrected 8 broken doctests (
server.settings.host/port→server.server_config.*; those fields don't exist on MCPServer v2). - Simplified
getattr(self, '_transport_security', None)to direct attribute access. - Updated stale "Fast MCP" docstring text and class
Examples:indentation. - Updated the plugin scaffold template to
mcp==2.0.0b1+mcp-types==2.0.0b1(was still on v1, which would have generated plugins incompatible with the v2 client). - Removed unused
ErrorDataimport and deadplugin._session_id = Nonetest setup; renamedhttp_client_instance→http_client.
Test coverage for the gaps:
- Split
test_invoke_hook_reconnects_on_session_terminated— the session-terminated success path was dead code shadowed by a second plugin setup, so the reconnect-then-retry path was never actually tested. Restored it and re-added the separate no-reconnect-on-other-errors test. - Added a test asserting
streamable_http_clientis called with a pre-builthttp_clientandterminate_on_close=True(the v2 API change + DELETE-on-close behavior that replaced the removed__terminate_http_session). - Added tests that UDS servers populate
_transport_securitywith DNS rebinding protection, non-UDS servers leave it unset, andrun_streamable_http_asyncforwards bothtransport_securityand the real bind host tostreamable_http_app().
Questions
-
Dependency pins —
pyproject.tomlpinsmcp==2.0.0b1andmcp-types==2.0.0b1exactly. In a library'sinstall_requires, an exact pin to a pre-release becomes a hard constraint on every downstream consumer, and oncemcp 2.0.0stable ships, consumers are blocked from upgrading until we cut a new release. Do you want to relax these to a range (e.g.>=2.0.0b1,<3), or is the exact pin intentional for beta stability until GA? I updated the scaffold template to match whatever we land on here. -
MIGRATION_MCP_V2.md— the 524-line strategy doc is committed at the repo root. It's useful as a record, but at the root next to README/CHANGELOG a future contributor might read its "planned" sections as outstanding work. Keep it at root, move it underdocs/, or drop it now that the migration is done?
|
In replay to questions above:
Yes, the dependency pins are only in place until 2.0.0 is GA, at which point PyPI versioning will take over again and they can be relaxed to a range. We will not push this PR prior to GA.
Same response. We will remove this document from the PR prior to merge -- the squash will take it out of git history. I'm switching the PR to draft for now... didn't realize it wasn't that way already. |
The b2 release replaces httpx with httpx2 internally. Update the MCP transport client factory in client.py to use httpx2.AsyncClient and related types. Adjust the corresponding test mock path accordingly.
Summary
Migrates all MCP client and server code from MCP Python SDK v1 → v2.0.0b1, applying every breaking API change.
Closes: #129
Breaking Changes Applied
streamablehttp_clientstreamable_http_clientMcpErrorMCPErrormcp.typesmcp_types(separate package)FastMCPMCPServerSSLCapableFastMCPSSLCapableMCPServerMCPServer(host=..., port=...)host/portpassed to run methods onlystreamable_http_clientreturns(read, write, get_session_id)(read, write)Behavioral Changes
__terminate_http_session(); v2 SDK handles DELETE viaterminate_on_close=True_http_client_factorypattern with pre-builthttpx.AsyncClienttransport_securitynow stored as instance attr and passed tostreamable_http_app()instead of__init__self.settings.host/port→self.server_config.host/portRemoved
_session_id,_get_session_id,_http_client_factory,__terminate_http_sessionTestTerminateHTTPSession,TestTerminateHTTPSessionErrors,TestHTTPClientFactory)Tests
All 190 MCP unit tests pass.
Dependencies
mcp == 2.0.0b1mcp-types == 2.0.0b1Migration guide reference
https://py.sdk.modelcontextprotocol.io/v2/migration/