Files
wehub-resource-sync 60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08:00

735 lines
28 KiB
Python

"""Dual-era protocol matrix: one FastMCP server served over both MCP protocol eras.
FastMCP must serve the legacy (initialize-handshake, 2025-11-25) era and the
modern (server/discover, 2026-07-28) era from the same server object. The test
harness is the v2 SDK's own first-class client, ``mcp.client.Client``, which
resolves an in-process ``Server`` directly:
* ``mode='legacy'`` forces the initialize handshake (2025-11-25 in-memory).
* ``mode='auto'`` probes ``server/discover`` and negotiates 2026-07-28.
* ``mode='2026-07-28'`` pins the modern version and adopts a synthesized
``DiscoverResult`` (no probe).
A FastMCP server exposes its lowlevel ``Server`` as ``fastmcp_server._mcp_server``;
that is what we hand to the SDK client, mirroring how
``mcp.client._memory.InMemoryTransport`` unwraps servers.
Several cells characterize behavior that is a verified SDK-era contract rather
than a FastMCP choice; those are flagged inline and cross-referenced to the
migration feedback dossier (``<scratchpad>/specs/sdk-feedback.md``).
"""
from __future__ import annotations
import mcp_types as types
import pytest
from mcp.client import Client as SDKClient
from mcp.client.session import ClientRequestContext
from mcp.server import Server as LowLevelServer
from mcp.shared.exceptions import MCPError
from mcp_types import methods
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
MODERN_PROTOCOL_VERSIONS,
)
from pydantic import FileUrl
from fastmcp import Client as FastMCPClient
from fastmcp import Context, FastMCP, settings
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.middleware import Middleware
# Modes that reach the modern (2026-07-28) era via the SDK client.
MODERN_MODES = ["auto", "2026-07-28"]
# Both eras, for cells that must produce identical semantics on each.
ALL_MODES = ["legacy", *MODERN_MODES]
@pytest.fixture
def dual_era_server() -> FastMCP:
"""A single FastMCP server exercising every core MCP object type.
Deliberately minimal and side-effect free so the same instance can be
driven concurrently by legacy and modern clients within one test.
"""
mcp = FastMCP("dual-era")
@mcp.tool
def add(a: int, b: int) -> int:
"""Structured-output tool (returns a scalar wrapped as {"result": ...})."""
return a + b
@mcp.resource("data://config")
def config() -> dict:
return {"version": 1}
@mcp.resource("data://item/{item_id}")
def item(item_id: str) -> str:
return f"item-{item_id}"
@mcp.prompt
def summarize(topic: str) -> str:
return f"Summarize {topic}"
return mcp
def _server(mcp: FastMCP) -> LowLevelServer:
"""The lowlevel Server the SDK client connects to in-process."""
return mcp._mcp_server
def _texts(blocks) -> list[str]:
"""Text from CallToolResult.content blocks (TextContent)."""
return [b.text for b in blocks if isinstance(b, types.TextContent)]
def _resource_texts(blocks) -> list[str]:
"""Text from ReadResourceResult.contents blocks (TextResourceContents)."""
return [b.text for b in blocks if isinstance(b, types.TextResourceContents)]
# ---------------------------------------------------------------------------
# 1. Core operations produce identical semantics on BOTH eras
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_list_tools_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["add"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_call_tool_structured_output_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.is_error is False
assert result.structured_content == {"result": 5}
assert _texts(result.content) == ["5"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_list_resources_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.list_resources()
assert [str(r.uri) for r in result.resources] == ["data://config"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_read_resource_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.read_resource("data://config")
assert _resource_texts(result.contents) == ['{"version": 1}']
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_read_resource_template_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.read_resource("data://item/42")
assert _resource_texts(result.contents) == ["item-42"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_list_prompts_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.list_prompts()
assert [p.name for p in result.prompts] == ["summarize"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_get_prompt_both_eras(dual_era_server, mode):
async with SDKClient(_server(dual_era_server), mode=mode) as client:
result = await client.get_prompt("summarize", {"topic": "cats"})
rendered = [
m.content.text
for m in result.messages
if isinstance(m.content, types.TextContent)
]
assert rendered == ["Summarize cats"]
@pytest.mark.parametrize("mode", ALL_MODES)
async def test_complete_parity_both_eras(dual_era_server, mode):
"""FastMCP registers no completion handler, so `completion/complete` is
method-not-found. The point of this cell is parity: the *same* -32601
surfaces on both eras (2026 did not change the unsupported-method contract).
"""
async with SDKClient(_server(dual_era_server), mode=mode) as client:
with pytest.raises(MCPError) as excinfo:
await client.complete(
types.PromptReference(name="summarize"),
{"name": "topic", "value": "c"},
)
assert excinfo.value.code == types.METHOD_NOT_FOUND
# ---------------------------------------------------------------------------
# 2. Discovery / identity: which negotiation path each mode takes
# ---------------------------------------------------------------------------
async def test_legacy_uses_initialize_handshake(dual_era_server):
"""Legacy mode runs the initialize handshake and reports a handshake-era
protocol version with server_info carried in the InitializeResult.
"""
async with SDKClient(_server(dual_era_server), mode="legacy") as client:
assert client.protocol_version == "2025-11-25"
assert client.server_info.name == "dual-era"
async def test_auto_negotiates_modern_via_discover(dual_era_server):
"""`mode='auto'` probes server/discover and adopts 2026-07-28, populating
server_info/capabilities from the DiscoverResult.
"""
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.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.
Characterization of the SDK's synthesize-discover path (mcp.client.client
`_synthesize_discover`): a pin without prior_discover trades identity for
skipping the probe round-trip.
"""
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 == ""
# ---------------------------------------------------------------------------
# 3. Push-feature degradation on 2026 vs. working callbacks on legacy
# ---------------------------------------------------------------------------
@pytest.fixture
def push_server() -> FastMCP:
mcp = FastMCP("push")
@mcp.tool
async def do_elicit(ctx: Context) -> str:
result = await ctx.elicit("pick a value", response_type=int)
assert isinstance(result, AcceptedElicitation)
return f"elicited {result.data}"
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_list_roots(ctx: Context) -> str:
roots = await ctx.list_roots()
return f"roots {[str(r.uri) for r in roots]}"
@mcp.tool
async def do_log(ctx: Context) -> str:
await ctx.info("a log line")
return "logged"
return mcp
async def _accept_elicit(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult:
return types.ElicitResult(action="accept", content={"value": 7})
async def _sampling_cb(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text="sampled-text"),
model="test-model",
)
async def _roots_cb(context: ClientRequestContext) -> types.ListRootsResult:
return types.ListRootsResult(
roots=[types.Root(uri=FileUrl("file:///tmp"), name="tmp")]
)
async def test_elicit_works_on_legacy(push_server):
async with SDKClient(
_server(push_server), mode="legacy", elicitation_callback=_accept_elicit
) as client:
result = await client.call_tool("do_elicit", {})
assert result.is_error is False
assert _texts(result.content) == ["elicited 7"]
async def test_sample_works_on_legacy(push_server):
async with SDKClient(
_server(push_server), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
assert _texts(result.content) == ["sampled sampled-text"]
async def test_list_roots_works_on_legacy(push_server):
async with SDKClient(
_server(push_server), mode="legacy", list_roots_callback=_roots_cb
) as client:
result = await client.call_tool("do_list_roots", {})
assert result.is_error is False
assert _texts(result.content) == ["roots ['file:///tmp']"]
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("tool", ["do_elicit", "do_sample", "do_list_roots"])
async def test_push_features_degrade_on_modern(push_server, mode, tool):
"""Server-initiated requests (elicitation/sampling/roots) are removed at
2026-07-28 (SEP-2577), so a tool that uses them must degrade to a surfaced
error rather than hang or crash the connection. This asserts the
degradation happens and reaches the caller as an isError result.
"""
async with SDKClient(
_server(push_server),
mode=mode,
elicitation_callback=_accept_elicit,
sampling_callback=_sampling_cb,
list_roots_callback=_roots_cb,
) as client:
result = await client.call_tool(tool, {})
assert result.is_error is True
# A subsequent normal call still works: the connection survived the
# per-request failure rather than tearing down the whole session.
log_result = await client.call_tool("do_log", {})
assert log_result.is_error is False
async def test_list_roots_degradation_message_is_clear_on_modern(push_server):
"""`ctx.list_roots()` sends with no related_request_id, so the SDK selects
the connection's no-back-channel outbound and raises the self-explanatory
NoBackChannelError. This is the *good* degradation message and we assert it.
"""
async with SDKClient(_server(push_server), mode="2026-07-28") as client:
result = await client.call_tool("do_list_roots", {})
assert result.is_error is True
message = " ".join(_texts(result.content)).lower()
assert "back-channel" in message and "server-initiated" in message
@pytest.mark.parametrize("tool", ["do_elicit", "do_sample"])
async def test_elicit_sample_degradation_message_is_clear_on_modern(push_server, tool):
"""FastMCP era-gates elicit/sample: on a 2026-07-28 connection they raise a
clear, era-aware error before hitting the wire, instead of the SDK's opaque
'Method not found' (sdk-feedback.md #10). Both messages name the removed
server-initiated capability so the caller knows why the request degraded.
"""
async with SDKClient(
_server(push_server),
mode="2026-07-28",
elicitation_callback=_accept_elicit,
sampling_callback=_sampling_cb,
) as client:
result = await client.call_tool(tool, {})
assert result.is_error is True
message = " ".join(_texts(result.content)).lower()
assert "server-initiated" in message
# ---------------------------------------------------------------------------
# 3a-bis. Server-configured sampling handler answers WITHOUT the client
# back-channel, so ctx.sample()/ctx.sample_step() must keep working on modern
# connections. The era-gate only fires when nothing can serve the request.
# ---------------------------------------------------------------------------
def _handler_server(behavior) -> FastMCP:
"""A server whose sampling is answered by a server-side handler."""
def sampling_handler(messages, params, ctx) -> str:
return "handler-answer"
mcp = FastMCP("handler", sampling_handler=sampling_handler)
if behavior is not None:
mcp.sampling_handler_behavior = behavior
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
step = await ctx.sample_step("hello")
return f"stepped {step.text}"
return mcp
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("behavior", ["always", "fallback"])
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_server_sampling_handler_works_on_modern(mode, behavior, method):
"""A server-side sampling handler answers entirely server-side, so it works
on modern (2026-07-28) connections regardless of behavior. The era-gate must
NOT block these — nothing touches the removed client back-channel. Crucially,
'fallback' must go straight to the handler (no bare client-attempt failure)."""
server = _handler_server(behavior)
async with SDKClient(_server(server), mode=mode) as client:
result = await client.call_tool(method, {})
assert result.is_error is False
assert "handler-answer" in " ".join(_texts(result.content))
@pytest.mark.parametrize("behavior", ["always", "fallback"])
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_server_sampling_handler_works_on_legacy(behavior, method):
"""Handshake-era behavior is unchanged: the server-side handler still answers
on legacy connections."""
server = _handler_server(behavior)
async with SDKClient(_server(server), mode="legacy") as client:
result = await client.call_tool(method, {})
assert result.is_error is False
assert "handler-answer" in " ".join(_texts(result.content))
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_sampling_without_handler_still_era_gated_on_modern(
push_server, mode, method
):
"""With no server-side handler configured, the request would hit the removed
client back-channel, so the clear era error still fires on modern."""
# push_server only defines do_sample; add a do_sample_step twin inline.
mcp = FastMCP("no-handler")
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
step = await ctx.sample_step("hello")
return f"stepped {step.text}"
async with SDKClient(
_server(mcp), mode=mode, sampling_callback=_sampling_cb
) as client:
result = await client.call_tool(method, {})
assert result.is_error is True
assert "server-initiated" in " ".join(_texts(result.content)).lower()
# ---------------------------------------------------------------------------
# 3b. Sampling deprecation warning (SEP-2577): ctx.sample/ctx.sample_step warn
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_sample_warn_flag():
"""Reset the process-wide warn-once flag so a warning can be observed."""
import fastmcp.server.context as context_module
original = set(context_module._sample_deprecation_warned)
context_module._sample_deprecation_warned.clear()
try:
yield
finally:
context_module._sample_deprecation_warned.clear()
context_module._sample_deprecation_warned.update(original)
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_sampling_emits_deprecation_warning(reset_sample_warn_flag, method):
"""`ctx.sample()` and `ctx.sample_step()` emit a FastMCPDeprecationWarning
naming SEP-2577 and the server-side-LLM migration path."""
from fastmcp.exceptions import FastMCPDeprecationWarning
mcp = FastMCP("warn")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
await ctx.sample_step("hello")
return "ok"
with pytest.warns(FastMCPDeprecationWarning, match="SEP-2577"):
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
await client.call_tool(method, {})
async def test_sampling_deprecation_warning_fires_once_per_process(
reset_sample_warn_flag,
):
"""The deprecation warning is warn-once: a second sample call in the same
process does not re-warn."""
from fastmcp.exceptions import FastMCPDeprecationWarning
mcp = FastMCP("warn-once")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
with pytest.warns(FastMCPDeprecationWarning):
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
await client.call_tool("do_sample", {})
import warnings as _warnings
with _warnings.catch_warnings():
_warnings.simplefilter("error", FastMCPDeprecationWarning)
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
async def test_sampling_deprecation_warning_suppressible_via_settings(
reset_sample_warn_flag, monkeypatch
):
"""Setting `deprecation_warnings=False` suppresses the sampling warning,
matching the house pattern for every other FastMCP deprecation."""
import warnings as _warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
monkeypatch.setattr(settings, "deprecation_warnings", False)
mcp = FastMCP("no-warn")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
with _warnings.catch_warnings():
_warnings.simplefilter("error", FastMCPDeprecationWarning)
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
@pytest.mark.parametrize("mode", MODERN_MODES)
async def test_logging_notification_still_flows_on_modern(push_server, mode):
"""`ctx.info` is a server->client *notification*, not a request. Unlike the
removed server-initiated requests, notifications still flow over the modern
direct-dispatcher path, so the tool completes successfully.
Characterization: current behavior is silent success (the log is emitted,
the tool returns normally); we assert that rather than an error.
"""
async with SDKClient(_server(push_server), mode=mode) as client:
result = await client.call_tool("do_log", {})
assert result.is_error is False
assert _texts(result.content) == ["logged"]
# ---------------------------------------------------------------------------
# 4. Tasks: submission + tasks/get across the eras the _sdk_patches shim covers
# ---------------------------------------------------------------------------
@pytest.fixture
def task_server() -> FastMCP:
mcp = FastMCP("tasks")
@mcp.tool(task=True)
async def slow_add(a: int, b: int) -> int:
return a + b
return mcp
async def test_task_submission_and_get_on_legacy_latest(task_server):
"""Legacy-latest (2025-11-25): a task-augmented tools/call returns a
CreateTaskResult and tasks/get resolves it. This exercises the
_sdk_patches registry-widening shim at the 2025-11-25 tools/call surface.
Driven with the FastMCP client because the v2 SDK client's call_tool has no
`task=` parameter (verified: mcp.client.session.ClientSession.call_tool
exposes no task metadata arg) — see item below.
"""
async with FastMCPClient(task_server) as client:
assert client.initialize_result is not None
assert client.initialize_result.protocol_version == "2025-11-25"
task = await client.call_tool("slow_add", {"a": 2, "b": 3}, task=True)
assert task.task_id
assert not task.returned_immediately
await task.wait(timeout=3.0)
result = await task.result()
assert result.data == 5
@pytest.mark.xfail(
strict=True,
reason=(
"The v2 SDK high-level client (mcp.client.Client) and ClientSession "
"expose no `task=` parameter on call_tool, so a task-augmented "
"tools/call cannot be submitted through it at any era; a hand-built "
"raw CallToolRequest does not drive FastMCP's task path either. On "
"2026-07-28 tasks moved to the io.modelcontextprotocol/tasks extension "
"and CreateTaskResult is not part of the tools/call union, so the "
"_sdk_patches shim intentionally does not widen the modern row "
"(sdk-feedback.md #1). Remove once the SDK client supports task "
"submission."
),
)
async def test_task_submission_on_modern(task_server):
async with SDKClient(_server(task_server), mode="2026-07-28") as client:
params = types.CallToolRequestParams(
name="slow_add",
arguments={"a": 1, "b": 2},
task=types.TaskMetadata(ttl=60000),
)
result = await client.session.send_request(
types.CallToolRequest(params=params), types.CreateTaskResult
)
assert isinstance(result, types.CreateTaskResult)
# ---------------------------------------------------------------------------
# 4b. _sdk_patches registry gating: the SEP-1686 task shim widens ONLY the
# handshake-era rows and leaves the 2026-07-28 (extension-era) rows untouched.
# ---------------------------------------------------------------------------
def test_task_shim_widens_handshake_tools_call_rows():
"""Every handshake-era tools/call row gains a CreateTaskResult arm."""
from fastmcp._sdk_patches import get_union_arms
for version in HANDSHAKE_PROTOCOL_VERSIONS:
row = methods.SERVER_RESULTS[("tools/call", version)]
assert types.CreateTaskResult in get_union_arms(row), version
def test_task_shim_does_not_touch_modern_tools_call_row():
"""The 2026-07-28 tools/call row stays the unpatched MRTR union: tasks are
the io.modelcontextprotocol/tasks extension there, so CreateTaskResult must
not be injected."""
from fastmcp._sdk_patches import get_union_arms
row = methods.SERVER_RESULTS[("tools/call", "2026-07-28")]
arms = get_union_arms(row)
assert types.CreateTaskResult not in arms
# Unchanged from the SDK default: the 2026 mutually-recursive tool result
# (CallToolResult | InputRequiredResult), keyed by the version-specific types.
arm_names = {arm.__name__ for arm in arms}
assert arm_names == {"CallToolResult", "InputRequiredResult"}
@pytest.mark.parametrize(
"task_method",
["tasks/get", "tasks/result", "tasks/list", "tasks/cancel"],
)
def test_task_shim_registers_tasks_rows_only_for_handshake_eras(task_method):
"""tasks/* result rows exist for handshake-era versions and are absent for
the modern (extension) era."""
for version in HANDSHAKE_PROTOCOL_VERSIONS:
assert (task_method, version) in methods.SERVER_RESULTS, (task_method, version)
for version in MODERN_PROTOCOL_VERSIONS:
assert (task_method, version) not in methods.SERVER_RESULTS, (
task_method,
version,
)
# ---------------------------------------------------------------------------
# 5. Sessionless safety: session-id-keyed paths must not crash on 2026 in-memory
# ---------------------------------------------------------------------------
@pytest.fixture
def sessionless_server() -> FastMCP:
mcp = FastMCP("sessionless")
@mcp.tool
async def read_session_id(ctx: Context) -> str:
# In-memory/HTTP-less connections have no HTTP session id; FastMCP must
# synthesize a stable one rather than raise.
return ctx.session_id
return mcp
@pytest.mark.parametrize("mode", MODERN_MODES)
async def test_session_id_access_does_not_crash_on_modern(sessionless_server, mode):
async with SDKClient(_server(sessionless_server), mode=mode) as client:
result = await client.call_tool("read_session_id", {})
assert result.is_error is False
# A non-empty synthesized id was returned.
assert _texts(result.content)[0]
@pytest.mark.parametrize("mode", MODERN_MODES)
async def test_set_logging_level_does_not_crash_on_modern(sessionless_server, mode):
"""logging/setLevel is a session-id-keyed, deprecated-at-2026 operation.
On a sessionless modern in-memory connection it must degrade cleanly (either
succeed as a no-op or raise a surfaced MCPError) rather than crash the
connection. Characterization: capture whichever the current contract is.
"""
async with SDKClient(_server(sessionless_server), mode=mode) as client:
outcome: str
try:
await client.set_logging_level("debug") # ty: ignore[deprecated]
outcome = "ok"
except MCPError:
outcome = "mcperror"
# Either way the connection is still usable afterward.
result = await client.call_tool("read_session_id", {})
assert result.is_error is False
assert outcome in {"ok", "mcperror"}
# ---------------------------------------------------------------------------
# 6. FastMCP middleware runs on both eras
# ---------------------------------------------------------------------------
class _CallToolCounter(Middleware):
def __init__(self) -> None:
super().__init__()
self.count = 0
async def on_call_tool(self, context, call_next):
self.count += 1
return await call_next(context)
async def test_middleware_runs_on_both_eras():
counter = _CallToolCounter()
mcp = FastMCP("mw")
mcp.add_middleware(counter)
@mcp.tool
def ping() -> str:
return "pong"
for mode in ("legacy", "2026-07-28"):
async with SDKClient(mcp._mcp_server, mode=mode) as client:
result = await client.call_tool("ping", {})
assert result.is_error is False
assert _texts(result.content) == ["pong"]
# One invocation observed from each era.
assert counter.count == 2