mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Move to the stable MCP Python SDK 2.0.0 (#4655)
This commit is contained in:
parent
81b1e818e5
commit
8b76710e66
41 changed files with 1505 additions and 1234 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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=="
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue