Fix client meta forwarding, ClientNotification construction, and v1 test idioms for SDK v2

This commit is contained in:
Jeremiah Lowin 2026-07-05 21:52:35 -04:00
commit 7389e294af
No known key found for this signature in database
6 changed files with 60 additions and 18 deletions

View file

@ -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)

View file

@ -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,
)
)

View file

@ -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}

View file

@ -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

View file

@ -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(

View file

@ -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