diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index be485bf49..0458ee50e 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -809,14 +809,12 @@ class Client( reason: str | None = None, ) -> None: """Send a cancellation notification for an in-progress request.""" - notification = mcp_types.ClientNotification( - root=mcp_types.CancelledNotification( - method="notifications/cancelled", - params=mcp_types.CancelledNotificationParams( - request_id=request_id, - reason=reason, - ), - ) + notification = mcp_types.CancelledNotification( + method="notifications/cancelled", + params=mcp_types.CancelledNotificationParams( + request_id=request_id, + reason=reason, + ), ) await self.session.send_notification(notification) diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py index 3e767949e..555c9553b 100644 --- a/fastmcp_slim/fastmcp/client/mixins/tools.py +++ b/fastmcp_slim/fastmcp/client/mixins/tools.py @@ -160,8 +160,14 @@ class ClientToolsMixin: ) as span: logger.debug(f"[{self.name}] called call_tool: {name}") - # Inject trace context into meta for propagation to server + # Inject trace context into meta for propagation to server. + # SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not + # the old `RequestParams.Meta` nested model. propagated_meta = inject_trace_context(meta) + request_meta = cast( + "mcp_types.RequestParamsMeta | None", + propagated_meta if propagated_meta else None, + ) result = await self._await_with_session_monitoring( self.session.call_tool( @@ -169,7 +175,7 @@ class ClientToolsMixin: arguments=arguments, read_timeout_seconds=normalize_timeout_to_seconds(timeout), progress_callback=progress_handler or self._progress_handler, - meta=propagated_meta if propagated_meta else None, + meta=request_meta, ) ) @@ -365,7 +371,7 @@ class ClientToolsMixin: # Use RootModel with Union to handle both response types (SDK calls model_validate) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + request=request, # type: ignore[arg-type] result_type=ToolTaskResponseUnion, ) ) diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index b10fd5d8a..86355ce9e 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -94,12 +94,13 @@ async def test_call_tool_with_meta(): assert context.request_context is not None meta = context.request_context.meta - # Return the metadata as a dict + # Return the metadata as a dict. Under SDK v2 the lifted request meta + # is a plain dict, so custom fields are read by key. if meta is not None: return { "has_meta": True, - "user_id": getattr(meta, "user_id", None), - "trace_id": getattr(meta, "trace_id", None), + "user_id": meta.get("user_id"), + "trace_id": meta.get("trace_id"), } return {"has_meta": False} diff --git a/tests/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py index 733f72085..229a1c02e 100644 --- a/tests/client/sampling/handlers/test_anthropic_handler.py +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -18,7 +18,6 @@ from mcp_types import ( ToolResultContent, ToolUseContent, ) -from pydantic import AnyUrl from fastmcp.client.sampling.handlers.anthropic import ( AnthropicSamplingHandler, @@ -387,7 +386,7 @@ def test_convert_messages_raises_on_unsupported_content_type(): embedded = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///test.txt"), text="hello", mime_type="text/plain" + uri="file:///test.txt", text="hello", mime_type="text/plain" ), ) # Must be inside a list content — single-content messages hit a diff --git a/tests/client/sampling/handlers/test_openai_handler.py b/tests/client/sampling/handlers/test_openai_handler.py index e50be7349..7370b6885 100644 --- a/tests/client/sampling/handlers/test_openai_handler.py +++ b/tests/client/sampling/handlers/test_openai_handler.py @@ -27,7 +27,6 @@ from openai.types.chat import ( ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion import Choice -from pydantic import AnyUrl from fastmcp.client.sampling.handlers.openai import ( OpenAISamplingHandler, @@ -331,7 +330,7 @@ def test_convert_messages_raises_on_unsupported_content_type(): embedded = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///test.txt"), text="hello", mime_type="text/plain" + uri="file:///test.txt", text="hello", mime_type="text/plain" ), ) msg = SamplingMessage.model_construct( diff --git a/tests/test_compat.py b/tests/test_compat.py index 214bc2fdc..330fc90af 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,5 +1,6 @@ """Tests for the MCP SDK v2 camelCase compatibility bridge (fastmcp._compat).""" +import datetime import warnings import mcp_types @@ -7,6 +8,8 @@ import pytest from mcp import MCPError as SDKMCPError import fastmcp._compat as _compat +from fastmcp import Client, FastMCP +from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError @@ -184,3 +187,39 @@ class TestExceptionAlias: def test_except_mcp_error_catches_sdk_raised(self): with pytest.raises(McpError): raise SDKMCPError(code=-32000, message="boom") + + +class TestClientBehaviorCompat: + """Behavior compat checklist (design decision D).""" + + @pytest.fixture + def server(self): + srv = FastMCP("BehaviorServer") + + @srv.tool + def echo(x: str) -> str: + return x + + return srv + + @pytest.mark.parametrize( + "timeout", + [5, 5.0, datetime.timedelta(seconds=5), None], + ) + async def test_client_accepts_timedelta_or_float_timeout(self, server, timeout): + client = Client(transport=FastMCPTransport(server), timeout=timeout) + async with client: + result = await client.call_tool("echo", {"x": "hi"}) + assert result.data == "hi" + + async def test_ping_returns_bool(self, server): + client = Client(transport=FastMCPTransport(server)) + async with client: + result = await client.ping() + assert result is True + + async def test_session_id_none_safe(self, server): + # In-memory transport has no HTTP session id; must return None, not raise. + client = Client(transport=FastMCPTransport(server)) + async with client: + assert client.transport.get_session_id() is None