Move to the stable MCP Python SDK 2.0.0 (#4655)

This commit is contained in:
Jeremiah Lowin 2026-07-28 16:31:49 -04:00 committed by GitHub
commit 8b76710e66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1505 additions and 1234 deletions

View file

@ -18,6 +18,7 @@ from fastmcp.client.transports import (
FastMCPTransport,
)
from fastmcp.server.server import FastMCP
from tests.conftest import user_meta
async def test_list_tools(fastmcp_server):
@ -848,7 +849,7 @@ async def test_client_unwraps_result_using_meta():
result = await client.call_tool("list_tool", {})
assert result.structured_content == {"result": [1, 2, 3]}
assert result.data == [1, 2, 3]
assert result.meta == {"fastmcp": {"wrap_result": True}}
assert user_meta(result.meta) == {"fastmcp": {"wrap_result": True}}
async def test_client_does_not_unwrap_dict_result():
@ -864,7 +865,7 @@ async def test_client_does_not_unwrap_dict_result():
result = await client.call_tool("dict_tool", {})
assert result.structured_content == {"a": 1}
assert result.data == {"a": 1}
assert result.meta is None
assert user_meta(result.meta) is None
async def test_client_list_dict_return_type():

View file

@ -244,12 +244,11 @@ class TestNonConformantModernPeer:
class TestPinnedMode:
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
"""Pinning the modern version adopts it directly; a synthesized
DiscoverResult leaves server_info empty."""
DiscoverResult carries no identity, so server_info is absent."""
async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client:
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.initialize_result is None
assert client.server_info is not None
assert client.server_info.name == ""
assert client.server_info is None
assert client.instructions is None
async def test_pinned_modern_call_tool(self, fastmcp_server):

View file

@ -28,7 +28,7 @@ EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml"
HOST = "127.0.0.1"
#: Pinned version of `@modelcontextprotocol/conformance`. Bump deliberately.
CONFORMANCE_VERSION = "0.2.0-alpha.9"
CONFORMANCE_VERSION = "0.2.0-alpha.10"
def _get_free_port() -> int:

View file

@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
import pytest
from mcp_types import SERVER_INFO_META_KEY
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
@ -23,6 +24,21 @@ if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
def user_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip the SDK's `serverInfo` stamp from a result's `_meta`.
Every 2026-era result carries `io.modelcontextprotocol/serverInfo` (spec
#3002), stamped by the SDK runner rather than by the component that
produced the result. Tests asserting on the meta a tool or resource set
itself use this to ignore the stamp, and get `None` back when the stamp was
the only entry.
"""
if meta is None:
return None
remaining = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY}
return remaining or None
def make_server_request_context(
*,
method: str = "tools/list",

View file

@ -5,6 +5,7 @@ from pydantic import AnyUrl, BaseModel
from fastmcp import Client, FastMCP
from fastmcp.resources import Resource, ResourceContent, ResourceResult
from fastmcp.resources.function_resource import FunctionResource
from tests.conftest import user_meta
class TestResourceValidation:
@ -323,7 +324,7 @@ class TestResourceMetaPropagation:
async with Client(mcp) as client:
result = await client.read_resource_mcp("test://with-meta")
assert result.meta == {"version": "2.0", "source": "test"}
assert user_meta(result.meta) == {"version": "2.0", "source": "test"}
async def test_resource_content_meta_received_by_client(self):
"""Meta set on ResourceContent is received by MCP client."""
@ -355,7 +356,7 @@ class TestResourceMetaPropagation:
async with Client(mcp) as client:
result = await client.read_resource_mcp("test://both-meta")
assert result.meta == {"result_key": "result_val"}
assert user_meta(result.meta) == {"result_key": "result_val"}
assert result.contents[0].meta == {"item_key": "item_val"}
async def test_json_native_return_preserves_component_meta(self):

View file

@ -35,6 +35,7 @@ from fastmcp.tools.tool_transform import (
)
from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.tests import run_server_async
from tests.conftest import user_meta
USERS = [
{"id": "1", "name": "Alice", "active": True},
@ -888,7 +889,12 @@ class TestPrompts:
result = await client.get_prompt("welcome", {"name": "Alice"})
async with Client(proxy_server) as client:
proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
# Each server stamps its own `serverInfo` into `_meta` (spec #3002), so
# the proxy's stamp naturally differs from the origin's. Compare the
# relayed payload.
assert proxy_result.model_copy(
update={"meta": user_meta(proxy_result.meta)}
) == result.model_copy(update={"meta": user_meta(result.meta)})
async def test_render_prompt_calls_prompt(self, proxy_server):
async with Client(proxy_server) as client:
@ -942,8 +948,11 @@ class TestPrompts:
async with Client(proxy_server) as client:
proxy_result = await client.get_prompt("image_prompt")
# The proxy result should match the original exactly
assert proxy_result == result
# The proxy relays the original payload; only the per-server
# `serverInfo` `_meta` stamp differs.
assert proxy_result.model_copy(
update={"meta": user_meta(proxy_result.meta)}
) == result.model_copy(update={"meta": user_meta(result.meta)})
# Verify the image content is preserved as ImageContent, not JSON text
assert isinstance(proxy_result.messages[1].content, mcp_types.ImageContent)
assert proxy_result.messages[1].content.data == "iVBORw0KGgoAAAANSUhEUg=="

View file

@ -172,6 +172,7 @@ async def test_legacy_uses_initialize_handshake(dual_era_server):
"""
async with SDKClient(_server(dual_era_server), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
assert client.server_info is not None
assert client.server_info.name == "dual-era"
@ -182,14 +183,16 @@ async def test_auto_negotiates_modern_via_discover(dual_era_server):
async with SDKClient(_server(dual_era_server), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
# server/discover carries identity, unlike the synthesized pin below.
assert client.server_info is not None
assert client.server_info.name == "dual-era"
assert client.server_capabilities is not None
async def test_pinned_modern_adopts_without_probe(dual_era_server):
"""Pinning `mode='2026-07-28'` adopts the version directly. With no
`prior_discover`, the SDK synthesizes a minimal DiscoverResult, so
server_info is empty even though the protocol version is modern.
`prior_discover`, the SDK synthesizes a minimal DiscoverResult that carries
no identity, so server_info is absent even though the protocol version is
modern.
Characterization of the SDK's synthesize-discover path (mcp.client.client
`_synthesize_discover`): a pin without prior_discover trades identity for
@ -197,7 +200,7 @@ async def test_pinned_modern_adopts_without_probe(dual_era_server):
"""
async with SDKClient(_server(dual_era_server), mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
assert client.server_info.name == ""
assert client.server_info is None
# ---------------------------------------------------------------------------

View file

@ -211,7 +211,6 @@ REMOVED_MODULES = [
"fastmcp.experimental.utilities.openapi", # -> fastmcp.utilities.openapi
"fastmcp.server.apps", # -> fastmcp.apps
"fastmcp.server.app", # -> fastmcp.apps / fastmcp
"mcp.types", # -> mcp_types
# The pre-rename component modules. `tool.py`/`resource.py`/`prompt.py` are
# now `base.py`; import the types from the package itself (`from
# fastmcp.tools import Tool`) rather than naming the private module.
@ -244,6 +243,20 @@ class TestRemovedSurfacesFailLoudly:
with pytest.raises(ModuleNotFoundError):
importlib.import_module(module_path)
def test_mcp_types_import_path_restored_by_stable_sdk(self):
# The MCP Python SDK beta (2.0.0b2, what v4 was built against) dropped
# `mcp.types` entirely, so `from mcp.types import X` was documented as a
# hard break requiring a switch to `from mcp_types import X`. The stable
# SDK release (2.0.0) reintroduced `mcp.types` as a deliberate mirror of
# `mcp_types` — same objects, same snake_case fields, not a v1 API
# restoration — specifically so old import paths keep working. Both
# spellings resolve to the identical class.
import mcp.types
import mcp_types
assert mcp.types.Tool is mcp_types.Tool
assert set(mcp.types.__all__) == set(mcp_types.__all__)
@pytest.mark.parametrize(
"module_path, name",
REMOVED_NAMES,

View file

@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
from fastmcp import Client, FastMCP
from fastmcp.tools.base import Tool, ToolResult
from tests.conftest import user_meta
class TestToolResultCasting:
@ -39,7 +40,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content is None
assert result.meta is None
assert user_meta(result.meta) is None
async def test_neither_unstructured_or_structured_content(self, client):
from fastmcp.exceptions import ToolError
@ -56,7 +57,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta is None
assert user_meta(result.meta) is None
async def test_structured_unstructured_and_meta_content(self, client):
result = await client.call_tool(
@ -71,7 +72,7 @@ class TestToolResultCasting:
assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta == {"some": "metadata"}
assert user_meta(result.meta) == {"some": "metadata"}
class TestToolResultIsError:
@ -153,7 +154,12 @@ class TestToolResultIsError:
async with Client(mcp) as client:
result = await client.call_tool_mcp("failing", {})
assert result.model_dump(by_alias=True) == raw_result.model_dump(by_alias=True)
received = result.model_dump(by_alias=True)
# The SDK stamps `serverInfo` into every 2026-era result's `_meta`
# (spec #3002). Strip it so the assertion covers the protocol fields
# the tool itself set, which is what FastMCP is responsible for.
received["_meta"] = user_meta(received["_meta"])
assert received == raw_result.model_dump(by_alias=True)
class TestUnionReturnTypes: