Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
This commit is contained in:
parent
44724284b3
commit
2397319a68
19 changed files with 2256 additions and 1088 deletions
|
|
@ -4,7 +4,8 @@ from pydantic import FileUrl
|
|||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.types import ListRootsResult, Root, TextContent
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import INVALID_REQUEST, ListRootsResult, Root, TextContent
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -37,10 +38,10 @@ async def test_list_roots_callback():
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without list_roots callback
|
||||
# Without a list_roots callback the client responds with an MCPError, which the
|
||||
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
|
||||
# error rather than wrapping it as an isError result.
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("test_list_roots", {"message": "test message"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
|
|
|||
|
|
@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
|
|||
|
||||
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
|
||||
await session.list_tools()
|
||||
|
||||
|
||||
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
|
||||
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
|
||||
|
||||
async def handle_mcp_request(request: Request) -> Response:
|
||||
data = json.loads(await request.body())
|
||||
if data.get("method") == "initialize":
|
||||
return _init_json_response(data)
|
||||
if "id" not in data:
|
||||
return Response(status_code=202)
|
||||
return Response(content=body, status_code=status, media_type="application/json")
|
||||
|
||||
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
|
||||
|
||||
|
||||
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
|
||||
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
|
||||
server set ``id: null`` — the client rewraps it under the pending request's id, so
|
||||
the awaiting call resolves with the server's error code instead of the generic fallback."""
|
||||
body = json.dumps(
|
||||
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
|
||||
).encode()
|
||||
app = _create_non_2xx_json_body_app(400, body)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.METHOD_NOT_FOUND
|
||||
|
||||
|
||||
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
|
||||
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
|
||||
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
|
||||
treated as the request's reply."""
|
||||
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.INTERNAL_ERROR
|
||||
|
||||
|
||||
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
|
||||
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
|
||||
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
|
||||
pending request — the parse failure never propagates."""
|
||||
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
|
||||
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
await session.initialize()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.list_tools()
|
||||
assert exc.value.error.code == types.INVALID_REQUEST
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import pytest
|
|||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import (
|
||||
INVALID_REQUEST,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
|
|
@ -47,13 +49,13 @@ async def test_sampling_callback():
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "true"
|
||||
|
||||
# Test without sampling callback
|
||||
# Without a sampling callback the client responds with an MCPError, which the
|
||||
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
|
||||
# error rather than wrapping it as an isError result.
|
||||
async with Client(server) as client:
|
||||
# Make a request to trigger sampling callback
|
||||
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -97,9 +97,9 @@ _TASKS_DEFERRAL = (
|
|||
)
|
||||
|
||||
_MODERN_NOTIFY_DROP = (
|
||||
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted "
|
||||
"logging/progress notifications never reach the per-request SSE response. Passes once SSE "
|
||||
"response mode lands."
|
||||
"The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; "
|
||||
"handler-emitted logging/progress notifications never reach the per-request SSE response. "
|
||||
"Passes once SSE response mode lands."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3046,6 +3046,50 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
|
||||
),
|
||||
"hosting:http:modern:discover-response-shape": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/index",
|
||||
behavior=(
|
||||
"A 2026-07-28 server/discover response carries supportedVersions, capabilities, and "
|
||||
"serverInfo, with supportedVersions naming the modern protocol revisions the server accepts."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: the raw result body is asserted at the wire.",
|
||||
),
|
||||
"hosting:http:modern:removed-method-status-404": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/index",
|
||||
behavior=(
|
||||
"A method that exists at earlier protocol revisions but is removed at 2026-07-28 is "
|
||||
"answered METHOD_NOT_FOUND, and the modern entry maps that error code to HTTP 404."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
transports=("streamable-http",),
|
||||
note=(
|
||||
"Only observable over streamable HTTP: the HTTP status is the assertion. Kernel-origin "
|
||||
"METHOD_NOT_FOUND travels through the same status table as classifier-origin errors."
|
||||
),
|
||||
),
|
||||
"hosting:http:modern:envelope-missing-key-status-400": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
|
||||
behavior=(
|
||||
"A 2026-07-28 request whose params._meta envelope omits a required reserved key is "
|
||||
"rejected as INVALID_PARAMS at HTTP 400 before kernel dispatch."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: the HTTP status is the assertion.",
|
||||
),
|
||||
"hosting:http:modern:handler-error-status-via-table": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A handler-raised MCPError on the 2026-07-28 entry reaches the wire as a top-level "
|
||||
"JSON-RPC error with its data preserved, and the HTTP status is the error-code table "
|
||||
"entry for that code (handler-origin and classifier-origin errors share one table)."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
|
||||
),
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Client transport: streamable HTTP
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@ from inline_snapshot import snapshot
|
|||
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
INVALID_PARAMS,
|
||||
PARSE_ERROR,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
|
|
@ -155,7 +160,12 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
|
|||
@requirement("hosting:http:protocol-version-400")
|
||||
@requirement("hosting:http:protocol-version-default")
|
||||
async def test_protocol_version_header_is_validated() -> None:
|
||||
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default."""
|
||||
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default.
|
||||
|
||||
An unrecognised header value routes to the modern entry (which owns rejection of unknown
|
||||
versions), and a request without the per-request envelope is rejected at the first ladder
|
||||
rung. Only known initialize-handshake versions and an absent header reach the legacy path.
|
||||
"""
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
session_id = await initialize_via_http(http)
|
||||
|
||||
|
|
@ -172,9 +182,7 @@ async def test_protocol_version_header_is_validated() -> None:
|
|||
)
|
||||
|
||||
assert bad.status_code == 400
|
||||
assert JSONRPCError.model_validate_json(bad.text).error.message.startswith(
|
||||
"Bad Request: Unsupported protocol version: 1991-01-01."
|
||||
)
|
||||
assert JSONRPCError.model_validate_json(bad.text).error.code == INVALID_PARAMS
|
||||
# 202 proves the request was accepted under the assumed default version (2025-03-26).
|
||||
assert defaulted.status_code == 202
|
||||
|
||||
|
|
@ -185,18 +193,27 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_
|
|||
|
||||
SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol
|
||||
version`` in the response body, so the literal must survive any rewording of the surrounding
|
||||
message. Asserted at the wire because the SDK client never surfaces the rejection body.
|
||||
message. The unsupported value must appear in both the header and the envelope so the
|
||||
classifier reaches its version-supported rung rather than reporting a header mismatch first.
|
||||
"""
|
||||
bad = "1991-01-01"
|
||||
meta = {
|
||||
PROTOCOL_VERSION_META_KEY: bad,
|
||||
CLIENT_INFO_META_KEY: {"name": "t", "version": "0"},
|
||||
CLIENT_CAPABILITIES_META_KEY: {},
|
||||
}
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
session_id = await initialize_via_http(http)
|
||||
response = await http.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
|
||||
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"},
|
||||
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
|
||||
headers=base_headers() | {"mcp-protocol-version": bad},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
error = JSONRPCError.model_validate_json(response.text).error
|
||||
assert error.code == UNSUPPORTED_PROTOCOL_VERSION
|
||||
assert "Unsupported protocol version" in response.text
|
||||
assert error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": bad}
|
||||
|
||||
|
||||
@requirement("hosting:http:json-response-mode")
|
||||
|
|
|
|||
|
|
@ -16,23 +16,29 @@ import httpx
|
|||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
JSONRPCError,
|
||||
JSONRPCResponse,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
RequestParams,
|
||||
TextContent,
|
||||
Tool,
|
||||
)
|
||||
from tests.interaction._connect import BASE_URL, base_headers, initialize_body, initialize_via_http, mounted_app
|
||||
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
|
@ -133,28 +139,30 @@ async def test_modern_response_carries_no_session_id_header() -> None:
|
|||
|
||||
@requirement("hosting:http:modern:initialize-removed")
|
||||
async def test_modern_initialize_is_method_not_found() -> None:
|
||||
"""A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.
|
||||
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
|
||||
|
||||
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the
|
||||
method/version gate rejects it before any handler runs. Asserted at the wire because the SDK
|
||||
client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative.
|
||||
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's
|
||||
method/version gate rejects it before any handler runs. The body must carry the per-request
|
||||
``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the
|
||||
envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire
|
||||
because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the
|
||||
negative.
|
||||
"""
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
response = await http.post("/mcp", json=initialize_body(), headers=_modern_headers(method="initialize"))
|
||||
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 404
|
||||
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:legacy-fallthrough")
|
||||
async def test_non_modern_version_header_falls_through_to_legacy_transport_unchanged() -> None:
|
||||
"""The 2026-07-28 routing branch fires only on its exact header; everything else reaches legacy.
|
||||
|
||||
SDK-defined under the draft versioning rules: the modern entry must not change any 2025-era
|
||||
byte. A 2025-era initialize on the same endpoint still completes (legacy serves it), and an
|
||||
unrecognised ``MCP-Protocol-Version`` still falls through to the legacy gate and produces the
|
||||
``Unsupported protocol version`` literal that peer SDKs substring-sniff. Asserted at the wire
|
||||
because the literal is only observable in the raw response body.
|
||||
async def test_legacy_version_header_falls_through_and_unrecognised_header_routes_to_modern() -> None:
|
||||
"""SDK-defined under the draft versioning rules: only the known initialize-handshake protocol
|
||||
versions reach the legacy transport, so a 2025-era ``initialize`` on the same endpoint still
|
||||
completes unchanged. Any other ``MCP-Protocol-Version`` value routes to the modern entry,
|
||||
where the validation ladder rejects it (a request without the per-request envelope fails the
|
||||
first rung). The modern entry is therefore the single owner of unknown-version rejection.
|
||||
"""
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
# 2025-era initialize through the same endpoint: the modern branch must not intercept it.
|
||||
|
|
@ -166,7 +174,7 @@ async def test_non_modern_version_header_falls_through_to_legacy_transport_uncha
|
|||
)
|
||||
|
||||
assert unrecognised.status_code == 400
|
||||
assert "Unsupported protocol version" in unrecognised.text
|
||||
assert JSONRPCError.model_validate_json(unrecognised.text).error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:handler-exception-internal-error")
|
||||
|
|
@ -198,6 +206,92 @@ async def test_modern_handler_exception_maps_to_internal_error_without_leaking_t
|
|||
assert "kaboom" not in error.message
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:discover-response-shape")
|
||||
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
|
||||
"""A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
|
||||
|
||||
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
|
||||
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
|
||||
per-request envelope version from. Asserted at the wire because the SDK client never exposes
|
||||
the raw result body.
|
||||
"""
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
|
||||
|
||||
assert response.status_code == 200
|
||||
result = JSONRPCResponse.model_validate(response.json()).result
|
||||
assert result["supportedVersions"] == snapshot(["2026-07-28"])
|
||||
assert result["serverInfo"]["name"] == "modern"
|
||||
assert "capabilities" in result
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:removed-method-status-404")
|
||||
async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
|
||||
"""A 2026-07-28 ping (removed at 2026) is answered METHOD_NOT_FOUND and the HTTP status is 404.
|
||||
|
||||
Spec-mandated for the error code: ping is not a defined method at 2026-07-28 so the kernel's
|
||||
method/version gate rejects it. SDK-defined for the HTTP status: kernel-origin METHOD_NOT_FOUND
|
||||
travels through the same error-code-to-status table as classifier-origin errors. Asserted at the
|
||||
wire because the HTTP status is the assertion.
|
||||
"""
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
|
||||
|
||||
assert response.status_code == 404
|
||||
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:envelope-missing-key-status-400")
|
||||
async def test_modern_envelope_missing_required_meta_key_is_invalid_params_at_http_400() -> None:
|
||||
"""A 2026-07-28 request whose ``_meta`` envelope omits a required key is INVALID_PARAMS at HTTP 400.
|
||||
|
||||
Spec-mandated under the draft transport: the per-request envelope must carry every reserved key,
|
||||
so a missing ``clientCapabilities`` fails the classifier's first rung before any kernel dispatch.
|
||||
Asserted at the wire because the HTTP status is the assertion.
|
||||
"""
|
||||
incomplete = _meta_envelope()
|
||||
del incomplete[CLIENT_CAPABILITIES_META_KEY]
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": incomplete}}
|
||||
async with mounted_app(_server()) as (http, _):
|
||||
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list"))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert JSONRPCError.model_validate(response.json()).error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:handler-error-status-via-table")
|
||||
async def test_modern_handler_raised_mcperror_maps_to_status_via_error_code_table() -> None:
|
||||
"""A handler-raised ``MCPError`` reaches the wire as a top-level JSON-RPC error at the table-mapped HTTP status.
|
||||
|
||||
SDK-defined for the HTTP status: the modern entry maps every JSON-RPC ``error.code`` -- whether
|
||||
classifier-origin or handler-origin -- through one error-code-to-status table, so a handler
|
||||
raising ``MISSING_REQUIRED_CLIENT_CAPABILITY`` produces HTTP 400 with ``error.data`` preserved.
|
||||
Spec-mandated for the error code: the named code and its ``requiredCapabilities`` data shape are
|
||||
the spec's capability-gating contract. Registered via the low-level ``add_request_handler`` so
|
||||
the high-level tool wrapper's error-swallowing is not on the path.
|
||||
"""
|
||||
|
||||
async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyResult:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message="sampling required",
|
||||
data={"requiredCapabilities": ["sampling"]},
|
||||
)
|
||||
|
||||
server = _server()
|
||||
server.add_request_handler("test/cap-check", RequestParams, cap_check)
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
|
||||
async with mounted_app(server) as (http, _):
|
||||
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
|
||||
|
||||
assert response.status_code == 400
|
||||
error = JSONRPCError.model_validate(response.json()).error
|
||||
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert error.data == {"requiredCapabilities": ["sampling"]}
|
||||
|
||||
|
||||
@requirement("hosting:http:modern:tools-call-stateless")
|
||||
@requirement("lifecycle:stateless:request-envelope")
|
||||
@requirement("lifecycle:stateless:caller-meta-preserved")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ from pydantic import BaseModel
|
|||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.elicitation import AcceptedElicitation
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import (
|
||||
INVALID_REQUEST,
|
||||
CallToolResult,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
|
|
@ -92,15 +94,15 @@ async def test_tool_calls_over_stateless_streamable_http() -> None:
|
|||
|
||||
@requirement("transport:streamable-http:stateless-restrictions")
|
||||
async def test_stateless_streamable_http_rejects_server_initiated_requests() -> None:
|
||||
"""A handler that tries to call back to the client in stateless mode fails: there is no session."""
|
||||
"""A handler that tries to call back to the client in stateless mode fails: there is no
|
||||
back-channel for server-initiated requests. The resulting ``NoBackChannelError`` is an
|
||||
``MCPError``, so it surfaces as a top-level JSON-RPC error rather than an
|
||||
``isError`` result."""
|
||||
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
|
||||
result = await client.call_tool("ask", {})
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("ask", {})
|
||||
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
# The exact message is the StatelessModeNotSupported exception text wrapped by the tool-error
|
||||
# path; pin the stable prefix rather than the full exception prose.
|
||||
assert result.content[0].text.startswith("Error executing tool ask:")
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@requirement("transport:streamable-http:notifications")
|
||||
|
|
|
|||
151
tests/server/lowlevel/test_server_discover.py
Normal file
151
tests/server/lowlevel/test_server_discover.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Direct-handler tests for the auto-derived `server/discover` handler.
|
||||
|
||||
These call the registered handler via the public `Server.get_request_handler`
|
||||
accessor without spinning up a `ServerRunner` or any transport, so they verify
|
||||
the handler's contract in isolation from the dispatch pipeline.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
# `Server._handle_discover` ignores its `ctx` argument entirely (it derives the
|
||||
# result from server state), so a sentinel keeps the call site type-correct
|
||||
# without dragging session machinery into a unit test.
|
||||
_UNUSED_CTX = cast("ServerRequestContext[Any]", None)
|
||||
|
||||
|
||||
async def _discover(server: Server[Any]) -> types.DiscoverResult:
|
||||
entry = server.get_request_handler("server/discover")
|
||||
assert entry is not None
|
||||
result = await entry.handler(_UNUSED_CTX, types.RequestParams())
|
||||
assert isinstance(result, types.DiscoverResult)
|
||||
return result
|
||||
|
||||
|
||||
def test_registered_by_default() -> None:
|
||||
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
|
||||
the box, typed for the base `RequestParams`."""
|
||||
server = Server("test-server")
|
||||
entry = server.get_request_handler("server/discover")
|
||||
assert entry is not None
|
||||
assert entry.params_type is types.RequestParams
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_supported_versions_is_modern_set() -> None:
|
||||
"""`supportedVersions` is exactly the modern envelope set, not the full
|
||||
legacy-compat list (D-008)."""
|
||||
result = await _discover(Server("test-server"))
|
||||
assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_info_reflects_constructor_fields() -> None:
|
||||
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
|
||||
constructor arguments."""
|
||||
icons = [types.Icon(src="https://example.test/icon.png")]
|
||||
server = Server(
|
||||
"info-server",
|
||||
version="9.9.9",
|
||||
title="Info Server",
|
||||
description="A server for testing discover.",
|
||||
website_url="https://example.test",
|
||||
icons=icons,
|
||||
)
|
||||
result = await _discover(server)
|
||||
assert result.server_info == types.Implementation(
|
||||
name="info-server",
|
||||
version="9.9.9",
|
||||
title="Info Server",
|
||||
description="A server for testing discover.",
|
||||
website_url="https://example.test",
|
||||
icons=icons,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_info_version_falls_back_to_package() -> None:
|
||||
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
|
||||
falls back to the installed `mcp` package version."""
|
||||
result = await _discover(Server("unversioned"))
|
||||
assert result.server_info.version == importlib.metadata.version("mcp")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_instructions_threaded_through() -> None:
|
||||
"""SDK-defined: the `instructions` constructor argument is passed through
|
||||
verbatim, defaulting to `None` when omitted."""
|
||||
server = Server("inst-server", instructions="Read the docs first.")
|
||||
result = await _discover(server)
|
||||
assert result.instructions == "Read the docs first."
|
||||
|
||||
bare = await _discover(Server("bare"))
|
||||
assert bare.instructions is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_capabilities_derived_from_registered_handlers() -> None:
|
||||
"""SDK-defined: capabilities are computed at handler call time from the
|
||||
live registry, so post-construction `add_request_handler` calls are
|
||||
reflected."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_prompts(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListPromptsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server("cap-server", on_list_tools=list_tools)
|
||||
|
||||
before = await _discover(server)
|
||||
assert before.capabilities.tools is not None
|
||||
assert before.capabilities.prompts is None
|
||||
|
||||
server.add_request_handler("prompts/list", types.PaginatedRequestParams, list_prompts)
|
||||
|
||||
after = await _discover(server)
|
||||
assert after.capabilities.tools is not None
|
||||
assert after.capabilities.prompts is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None:
|
||||
"""SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler
|
||||
relies on the model defaults (immediately-stale, private)."""
|
||||
result = await _discover(Server("cache-server"))
|
||||
assert result.ttl_ms == 0
|
||||
assert result.cache_scope == "private"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_overridable_via_add_request_handler() -> None:
|
||||
"""SDK-defined: a custom `server/discover` handler registered via
|
||||
`add_request_handler` replaces the auto-derived default wholesale."""
|
||||
server = Server("custom-server", version="1.0.0")
|
||||
custom = types.DiscoverResult(
|
||||
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
|
||||
capabilities=types.ServerCapabilities(),
|
||||
server_info=types.Implementation(name="custom-server", version="1.0.0"),
|
||||
instructions="overridden",
|
||||
ttl_ms=60_000,
|
||||
cache_scope="public",
|
||||
)
|
||||
|
||||
async def custom_discover(
|
||||
ctx: ServerRequestContext[Any], params: types.RequestParams | None
|
||||
) -> types.DiscoverResult:
|
||||
return custom
|
||||
|
||||
server.add_request_handler("server/discover", types.RequestParams, custom_discover)
|
||||
result = await _discover(server)
|
||||
assert result is custom
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
from mcp.server.mcpserver import Context
|
||||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.tools.base import Tool
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
||||
def test_context_detected_in_union_annotation():
|
||||
|
|
@ -8,3 +12,45 @@ def test_context_detected_in_union_annotation():
|
|||
|
||||
tool = Tool.from_function(my_tool)
|
||||
assert tool.context_kwarg == "ctx"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
|
||||
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
|
||||
and means "respond with a protocol error". The tool wrapper re-raises it so
|
||||
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
|
||||
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
|
||||
mcp = MCPServer(name="srv")
|
||||
|
||||
@mcp.tool()
|
||||
async def needs_sampling() -> str:
|
||||
raise MCPError(
|
||||
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
"sampling capability required",
|
||||
data={"requiredCapabilities": ["sampling"]},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("needs_sampling", {})
|
||||
|
||||
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
|
||||
"""SDK-defined: ordinary exceptions from a tool body are execution failures
|
||||
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
|
||||
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
|
||||
mcp = MCPServer(name="srv")
|
||||
|
||||
@mcp.tool()
|
||||
async def boom() -> str:
|
||||
raise RuntimeError("execution failure")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("boom", {})
|
||||
|
||||
assert isinstance(result, types.CallToolResult)
|
||||
assert result.is_error is True
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Tests for `Connection`.
|
||||
|
||||
`Connection` wraps an `Outbound` (the standalone stream). Its `notify` is
|
||||
best-effort (never raises); `send_raw_request` is gated on
|
||||
`has_standalone_channel`. Tested with a stub `Outbound` so we can assert wire
|
||||
shape and inject failures.
|
||||
`Connection` wraps an `Outbound` (the standalone stream). Construct it via the
|
||||
`from_envelope` / `for_loop` factories so `protocol_version` is always
|
||||
populated and `has_standalone_channel` is derived from the held outbound. Its
|
||||
`notify` is best-effort (never raises); `send_raw_request` raises
|
||||
`NoBackChannelError` structurally from the no-channel sentinel. Tested with a
|
||||
stub `Outbound` so we can assert wire shape and inject failures.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -17,6 +19,7 @@ from pydantic import BaseModel, ValidationError
|
|||
from mcp.server.connection import Connection
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
|
|
@ -25,7 +28,6 @@ from mcp.types import (
|
|||
ElicitationCapability,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
PingRequest,
|
||||
|
|
@ -37,13 +39,8 @@ from mcp.types import (
|
|||
SamplingToolsCapability,
|
||||
)
|
||||
|
||||
|
||||
def _client_params(capabilities: ClientCapabilities) -> InitializeRequestParams:
|
||||
return InitializeRequestParams(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
capabilities=capabilities,
|
||||
client_info=Implementation(name="t", version="0"),
|
||||
)
|
||||
_CLIENT_INFO = Implementation(name="t", version="0")
|
||||
_MODERN = MODERN_PROTOCOL_VERSIONS[0]
|
||||
|
||||
|
||||
class StubOutbound:
|
||||
|
|
@ -67,10 +64,83 @@ class StubOutbound:
|
|||
self.notifications.append((method, params))
|
||||
|
||||
|
||||
# --- factories -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_envelope_is_born_ready_with_no_back_channel():
|
||||
"""SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`,
|
||||
and holds the no-channel sentinel so `has_standalone_channel` derives False."""
|
||||
conn = Connection.from_envelope(_MODERN, None, None)
|
||||
assert conn.protocol_version == _MODERN
|
||||
assert conn.initialized.is_set()
|
||||
assert conn.initialize_accepted is True
|
||||
assert conn.has_standalone_channel is False
|
||||
assert conn.client_params is None
|
||||
assert conn.session_id is None
|
||||
|
||||
|
||||
def test_from_envelope_records_client_params_when_both_info_and_caps_supplied():
|
||||
"""SDK-defined: when both client info and capabilities are supplied,
|
||||
`from_envelope` synthesizes `client_params` so capability checks can run."""
|
||||
caps = ClientCapabilities(sampling=SamplingCapability())
|
||||
conn = Connection.from_envelope(_MODERN, _CLIENT_INFO, caps)
|
||||
assert conn.client_params is not None
|
||||
assert conn.client_params.client_info.name == "t"
|
||||
assert conn.client_params.capabilities.sampling is not None
|
||||
assert conn.client_params.protocol_version == _MODERN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("info", "caps"),
|
||||
[(None, ClientCapabilities()), (_CLIENT_INFO, None)],
|
||||
)
|
||||
def test_from_envelope_leaves_client_params_none_when_either_is_missing(
|
||||
info: Implementation | None, caps: ClientCapabilities | None
|
||||
):
|
||||
"""SDK-defined: `client_params` is only synthesized when both info and
|
||||
caps are present; either missing leaves it `None`."""
|
||||
conn = Connection.from_envelope(_MODERN, info, caps)
|
||||
assert conn.client_params is None
|
||||
|
||||
|
||||
def test_from_envelope_with_explicit_outbound_has_standalone_channel():
|
||||
"""SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel`
|
||||
derives True since the held outbound is not the no-channel sentinel."""
|
||||
out = StubOutbound()
|
||||
conn = Connection.from_envelope(_MODERN, None, None, outbound=out)
|
||||
assert conn.has_standalone_channel is True
|
||||
assert conn.outbound is out
|
||||
assert conn.initialized.is_set()
|
||||
|
||||
|
||||
def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready():
|
||||
"""SDK-defined: `for_loop` seeds `protocol_version` from the hint when given,
|
||||
else `LATEST_PROTOCOL_VERSION`; the connection awaits the initialize handshake."""
|
||||
out = StubOutbound()
|
||||
conn = Connection.for_loop(out)
|
||||
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
assert conn.has_standalone_channel is True
|
||||
assert not conn.initialized.is_set()
|
||||
assert conn.initialize_accepted is False
|
||||
assert conn.client_params is None
|
||||
|
||||
hinted = Connection.for_loop(out, protocol_version_hint=_MODERN)
|
||||
assert hinted.protocol_version == _MODERN
|
||||
|
||||
|
||||
def test_for_loop_records_session_id_when_supplied():
|
||||
"""SDK-defined: `for_loop` stores the `session_id` kwarg verbatim."""
|
||||
conn = Connection.for_loop(StubOutbound(), session_id="sess-1")
|
||||
assert conn.session_id == "sess-1"
|
||||
|
||||
|
||||
# --- outbound channel ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_notify_forwards_to_outbound():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.notify("notifications/message", {"level": "info", "data": "hi"})
|
||||
assert out.notifications == [("notifications/message", {"level": "info", "data": "hi"})]
|
||||
|
||||
|
|
@ -79,24 +149,24 @@ async def test_connection_notify_forwards_to_outbound():
|
|||
async def test_connection_notify_swallows_broken_stream_and_debug_logs(caplog: pytest.LogCaptureFixture):
|
||||
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
|
||||
out = StubOutbound(raise_on_send=anyio.BrokenResourceError)
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.notify("notifications/message", {"data": "x"}) # must not raise
|
||||
assert "stream closed" in caplog.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_notify_drops_when_no_standalone_channel(caplog: pytest.LogCaptureFixture):
|
||||
"""SDK-defined: the no-channel sentinel debug-logs and drops; `notify` never raises."""
|
||||
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=False)
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
await conn.notify("notifications/message", {"data": "x"}) # must not raise
|
||||
assert out.notifications == []
|
||||
assert "no standalone channel" in caplog.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalone_channel():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=False)
|
||||
"""SDK-defined: the no-channel sentinel raises structurally; `Connection` does no pre-check."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
with pytest.raises(NoBackChannelError):
|
||||
await conn.send_raw_request("ping", None)
|
||||
|
||||
|
|
@ -104,7 +174,7 @@ async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalo
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_raw_request_forwards_when_standalone_channel_present():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_raw_request("ping", None)
|
||||
assert out.requests == [("ping", None)]
|
||||
assert result == {}
|
||||
|
|
@ -113,7 +183,7 @@ async def test_connection_send_raw_request_forwards_when_standalone_channel_pres
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_with_spec_type_infers_result_type():
|
||||
out = StubOutbound(result={"roots": [{"uri": "file:///ws"}]})
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_request(ListRootsRequest())
|
||||
method, _ = out.requests[0]
|
||||
assert method == "roots/list"
|
||||
|
|
@ -126,7 +196,7 @@ async def test_connection_send_request_validates_result_alias_only():
|
|||
"""Peer results validate alias-only; a snake_case key from the wire is
|
||||
ignored as extra, not populated by Python field name."""
|
||||
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
|
||||
conn = Connection(StubOutbound(result=snake), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result=snake))
|
||||
result = await conn.send_request(CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=1)))
|
||||
assert result.stop_reason is None
|
||||
|
||||
|
|
@ -134,14 +204,14 @@ async def test_connection_send_request_validates_result_alias_only():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_with_result_type_kwarg_validates_custom_type():
|
||||
out = StubOutbound(result={})
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
result = await conn.send_request(PingRequest(), result_type=EmptyResult)
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_send_request_nonconforming_result_raises_validation_error():
|
||||
conn = Connection(StubOutbound(result={"bogus": 1}), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result={"bogus": 1}))
|
||||
with pytest.raises(ValidationError):
|
||||
await conn.send_request(ListRootsRequest())
|
||||
|
||||
|
|
@ -150,7 +220,7 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
|
|||
async def test_send_request_validates_the_client_result_against_the_surface_schema():
|
||||
"""A spec-method result that fails the per-version surface schema raises
|
||||
`ValidationError` even when the caller's `result_type` would accept it."""
|
||||
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
|
||||
conn = Connection.for_loop(StubOutbound(result={"roots": "nope"}))
|
||||
with pytest.raises(ValidationError):
|
||||
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
|
||||
|
||||
|
|
@ -158,8 +228,8 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
|
|||
@pytest.mark.anyio
|
||||
async def test_send_request_passes_a_spec_valid_client_result():
|
||||
"""A spec-valid client result passes the surface gate and parses to the typed model."""
|
||||
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
|
||||
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
result = await conn.send_request(ListRootsRequest())
|
||||
assert isinstance(result, ListRootsResult)
|
||||
assert str(result.roots[0].uri) == "file:///ws"
|
||||
|
|
@ -178,8 +248,7 @@ class _CustomResult(BaseModel):
|
|||
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
|
||||
"""Surface row absent for the negotiated version: gate is bypassed and only
|
||||
the inferred result type validates."""
|
||||
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2026-07-28"
|
||||
conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=_MODERN)
|
||||
result = await conn.send_request(PingRequest())
|
||||
assert isinstance(result, EmptyResult)
|
||||
|
||||
|
|
@ -187,8 +256,7 @@ async def test_send_request_skips_the_surface_gate_when_method_absent_at_version
|
|||
@pytest.mark.anyio
|
||||
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
|
||||
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
|
||||
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
|
||||
conn.protocol_version = "2025-11-25"
|
||||
conn = Connection.for_loop(StubOutbound(result={"value": 7}))
|
||||
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
|
||||
assert isinstance(result, _CustomResult)
|
||||
assert result.value == 7
|
||||
|
|
@ -197,7 +265,7 @@ async def test_send_request_with_a_custom_method_skips_the_surface_gate():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_ping_sends_ping_on_standalone():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.ping()
|
||||
assert out.requests == [("ping", None)]
|
||||
|
||||
|
|
@ -205,7 +273,7 @@ async def test_connection_ping_sends_ping_on_standalone():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_log_sends_logging_message_notification():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.log("info", {"k": "v"}, logger="my.logger") # pyright: ignore[reportDeprecated]
|
||||
method, params = out.notifications[0]
|
||||
assert method == "notifications/message"
|
||||
|
|
@ -218,7 +286,7 @@ async def test_connection_log_sends_logging_message_notification():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_log_with_meta_includes_meta_in_params():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.log("info", "x", meta={"traceId": "abc"}) # pyright: ignore[reportDeprecated]
|
||||
_, params = out.notifications[0]
|
||||
assert params is not None
|
||||
|
|
@ -228,7 +296,7 @@ async def test_connection_log_with_meta_includes_meta_in_params():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_list_changed_notifications_send_correct_methods():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.send_tool_list_changed()
|
||||
await conn.send_prompt_list_changed()
|
||||
await conn.send_resource_list_changed()
|
||||
|
|
@ -246,14 +314,21 @@ async def test_connection_list_changed_notifications_send_correct_methods():
|
|||
@pytest.mark.anyio
|
||||
async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_params():
|
||||
out = StubOutbound()
|
||||
conn = Connection(out, has_standalone_channel=True)
|
||||
conn = Connection.for_loop(out)
|
||||
await conn.send_tool_list_changed(meta={"k": 1})
|
||||
assert out.notifications == [("notifications/tools/list_changed", {"_meta": {"k": 1}})]
|
||||
|
||||
|
||||
def test_connection_check_capability_false_before_initialized():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
# --- check_capability ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_connection_check_capability_false_when_no_client_params_recorded():
|
||||
"""SDK-defined: `check_capability` returns False when no `client_params`
|
||||
were recorded, regardless of which factory built the connection."""
|
||||
conn = Connection.for_loop(StubOutbound())
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
|
||||
# Same for a born-ready connection that supplied neither info nor caps.
|
||||
assert Connection.from_envelope(_MODERN, None, None).check_capability(ClientCapabilities()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -281,6 +356,11 @@ def test_connection_check_capability_false_before_initialized():
|
|||
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
|
||||
True,
|
||||
),
|
||||
(
|
||||
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
|
||||
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
|
||||
True,
|
||||
),
|
||||
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
|
||||
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
|
||||
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
|
||||
|
|
@ -288,17 +368,16 @@ def test_connection_check_capability_false_before_initialized():
|
|||
],
|
||||
)
|
||||
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
conn.client_params = _client_params(have)
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, _CLIENT_INFO, have)
|
||||
assert conn.check_capability(want) is expected
|
||||
|
||||
|
||||
def test_connection_check_capability_true_when_client_declares_it():
|
||||
conn = Connection(StubOutbound(), has_standalone_channel=True)
|
||||
conn.client_params = _client_params(
|
||||
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True))
|
||||
conn = Connection.from_envelope(
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
_CLIENT_INFO,
|
||||
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True)),
|
||||
)
|
||||
conn.initialized.set()
|
||||
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
||||
assert conn.check_capability(ClientCapabilities(roots=RootsCapability(list_changed=True))) is True
|
||||
assert conn.check_capability(ClientCapabilities(elicitation=ElicitationCapability())) is False
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ async def test_server_run_exits_cleanly_when_transport_yields_exception_then_clo
|
|||
with anyio.fail_after(5):
|
||||
# stateless=True so server.run doesn't wait for initialize handshake.
|
||||
# Before the fix, this raised ExceptionGroup(ClosedResourceError).
|
||||
await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True)
|
||||
await server.run(read_recv, write_send, server.create_initialization_options())
|
||||
|
||||
# write_send was closed inside run's `async with`; receive_nowait raises
|
||||
# EndOfStream iff the buffer is empty (i.e., server wrote nothing).
|
||||
|
|
|
|||
|
|
@ -1,49 +1,63 @@
|
|||
"""Tests for `ServerRunner`.
|
||||
"""Tests for `ServerRunner` and the free-function drivers.
|
||||
|
||||
End-to-end over `JSONRPCDispatcher` with a real lowlevel `Server` as the
|
||||
registry. The `connected_runner` helper starts both sides and (by default)
|
||||
performs the initialize handshake, so each test exercises only the behaviour
|
||||
under test.
|
||||
The kernel tests run end-to-end over `JSONRPCDispatcher` with a real lowlevel
|
||||
`Server` as the registry. The `connected_runner` helper starts both sides and
|
||||
(by default) performs the initialize handshake, so each test exercises only the
|
||||
behaviour under test. Driver tests (`serve_connection`, `serve_one`,
|
||||
`to_jsonrpc_response`, `aclose_shielded`) follow at the bottom.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
import pytest
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
|
||||
import mcp.server.runner
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import NotificationOptions, Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
|
||||
from mcp.server.runner import (
|
||||
ServerRunner,
|
||||
_extract_meta,
|
||||
aclose_shielded,
|
||||
otel_middleware,
|
||||
serve_connection,
|
||||
serve_one,
|
||||
to_jsonrpc_response,
|
||||
)
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
||||
from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.message import MessageMetadata
|
||||
from mcp.shared.peer import dump_params
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
CallToolRequestParams,
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ListToolsResult,
|
||||
NotificationParams,
|
||||
PaginatedRequestParams,
|
||||
ProgressNotificationParams,
|
||||
RequestParams,
|
||||
RequestParamsMeta,
|
||||
SetLevelRequestParams,
|
||||
Tool,
|
||||
)
|
||||
|
|
@ -84,36 +98,46 @@ async def connected_runner(
|
|||
server: SrvT,
|
||||
*,
|
||||
initialized: bool = True,
|
||||
stateless: bool = False,
|
||||
has_standalone_channel: bool = True,
|
||||
init_options: InitializationOptions | None = None,
|
||||
session_id: str | None = None,
|
||||
dispatch_middleware: list[DispatchMiddleware] | None = None,
|
||||
connection: Connection | None = None,
|
||||
) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], ServerRunner[dict[str, Any]]]]:
|
||||
"""Yield `(client, runner)` running over an in-memory JSON-RPC dispatcher pair.
|
||||
|
||||
Starts the client (echo handlers) and `runner.run()` in a task group, wraps
|
||||
the body in `anyio.fail_after(5)`, and cancels on exit. When
|
||||
`initialized` is true the helper performs the real `initialize` request
|
||||
before yielding, so tests start past the init-gate via the public path.
|
||||
Starts the client (echo handlers) and the server-side dispatcher loop
|
||||
(kernel `on_request`/`on_notify` + `aclose_shielded` teardown - the
|
||||
`serve_connection` shape) in a task group, wraps the body in
|
||||
`anyio.fail_after(5)`, and cancels on exit. When `initialized` is true the
|
||||
helper performs the real `initialize` request before yielding, so tests
|
||||
start past the init-gate via the public path.
|
||||
|
||||
`connection` defaults to `Connection.for_loop(server_dispatcher)`. Pass a
|
||||
factory-built connection (e.g. `Connection.from_envelope(...)`) to exercise
|
||||
the born-ready path; the kernel reads it as a fact and is mode-agnostic.
|
||||
"""
|
||||
client, server_d, close = jsonrpc_pair()
|
||||
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
|
||||
if connection is None:
|
||||
connection = Connection.for_loop(server_d)
|
||||
runner = ServerRunner(
|
||||
server=server,
|
||||
dispatcher=server_d,
|
||||
connection=connection,
|
||||
lifespan_state={},
|
||||
has_standalone_channel=has_standalone_channel,
|
||||
init_options=init_options,
|
||||
session_id=session_id,
|
||||
stateless=stateless,
|
||||
dispatch_middleware=dispatch_middleware or [],
|
||||
)
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
body_exc: BaseException | None = None
|
||||
|
||||
async def _drive(*, task_status: anyio.abc.TaskStatus[None]) -> None:
|
||||
try:
|
||||
await server_d.run(runner.on_request, runner.on_notify, task_status=task_status)
|
||||
finally:
|
||||
await aclose_shielded(connection)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
await tg.start(runner.run)
|
||||
await tg.start(_drive)
|
||||
try:
|
||||
with anyio.fail_after(5):
|
||||
if initialized:
|
||||
|
|
@ -219,11 +243,21 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
|
|||
assert isinstance(ctx, ServerRequestContext)
|
||||
assert ctx.lifespan_context == {}
|
||||
assert isinstance(ctx.session, ServerSession)
|
||||
assert ctx.session is runner.session
|
||||
assert ctx.session.protocol_version == runner.connection.protocol_version
|
||||
assert ctx.request_id is not None
|
||||
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_builds_a_fresh_session_per_request(server: SrvT):
|
||||
"""`ctx.session` is built per-request from the per-request `DispatchContext`
|
||||
and the connection's standalone outbound; it is not connection-scoped."""
|
||||
async with connected_runner(server) as (client, _):
|
||||
await client.send_raw_request("tools/list", None)
|
||||
await client.send_raw_request("tools/list", None)
|
||||
assert _seen_ctx[0].session is not _seen_ctx[1].session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_spec_method_with_no_handler_raises_method_not_found(server: SrvT):
|
||||
async with connected_runner(server) as (client, _):
|
||||
|
|
@ -605,7 +639,6 @@ async def test_runner_middleware_raise_after_call_next_on_initialize_leaves_conn
|
|||
assert await client.send_raw_request("ping", None) == {}
|
||||
assert runner.connection.initialize_accepted is False
|
||||
assert runner.connection.client_params is None
|
||||
assert runner.connection.protocol_version is None
|
||||
assert not runner.connection.initialized.is_set()
|
||||
|
||||
|
||||
|
|
@ -656,65 +689,6 @@ async def test_runner_server_middleware_wraps_notifications(server: SrvT):
|
|||
]
|
||||
|
||||
|
||||
def test_resolve_protocol_version_handshake_committed_value_wins():
|
||||
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
|
||||
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
|
||||
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
|
||||
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_skips_unsupported_meta_value():
|
||||
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
|
||||
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_skips_non_string_meta_value():
|
||||
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
|
||||
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
|
||||
md = ServerMessageMetadata(protocol_version="2025-06-18")
|
||||
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
|
||||
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_skips_unsupported_transport_hint():
|
||||
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
|
||||
md = ServerMessageMetadata(protocol_version="1900-01-01")
|
||||
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
|
||||
|
||||
|
||||
def test_resolve_protocol_version_terminal_default_with_no_signals():
|
||||
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
|
||||
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
|
||||
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
|
||||
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
|
||||
await client.send_raw_request("tools/list", None)
|
||||
ctx = _seen_ctx[0]
|
||||
assert ctx.protocol_version == "2025-11-25"
|
||||
assert ctx.session.protocol_version is None
|
||||
assert runner.connection.protocol_version is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
|
||||
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
|
||||
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
|
||||
assert _seen_ctx[0].protocol_version == "2025-06-18"
|
||||
|
||||
|
||||
def test_extract_meta_returns_none_for_absent_or_malformed():
|
||||
"""Context construction is independent of `_meta` validity; the params
|
||||
validation inside `call_next()` is what surfaces the error."""
|
||||
|
|
@ -837,21 +811,16 @@ async def test_runner_handler_returning_unsupported_type_surfaces_as_error(serve
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_stateless_skips_init_gate(server: SrvT):
|
||||
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (client, _):
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_stateless_connection_initialized_event_set_on_construction(server: SrvT):
|
||||
"""`connection.initialized` mirrors the gate flag in stateless mode so
|
||||
`await connection.initialized.wait()` does not hang when no handshake
|
||||
arrives."""
|
||||
async with connected_runner(server, initialized=False, stateless=True, has_standalone_channel=False) as (_, runner):
|
||||
async def test_runner_with_born_ready_connection_skips_init_gate(server: SrvT):
|
||||
"""A `Connection.from_envelope` connection is born ready: the kernel's
|
||||
init-gate is open without any handshake. The kernel is mode-agnostic - the
|
||||
same `on_request` reads `connection.initialize_accepted` as a fact."""
|
||||
born_ready = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
|
||||
assert runner.connection.initialize_accepted is True
|
||||
assert runner.connection.initialized.is_set()
|
||||
await runner.connection.initialized.wait()
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -954,6 +923,33 @@ async def test_runner_spec_method_absent_at_the_negotiated_version_is_method_not
|
|||
assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="server/discover")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_request_rejects_initialize_at_modern_version_with_method_not_found(server: SrvT):
|
||||
"""Spec-mandated: `initialize` has no `CLIENT_REQUESTS` row at the modern
|
||||
version; kernel dispatch (not the inbound classifier) rejects it."""
|
||||
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner):
|
||||
assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0]
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("initialize", _initialize_params())
|
||||
assert exc.value.error.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_request_dispatches_custom_method_registered_via_add_request_handler(server: SrvT):
|
||||
"""SDK-defined: a method outside `SPEC_CLIENT_METHODS` skips the version
|
||||
gate and reaches its registered handler at any negotiated version."""
|
||||
|
||||
async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
|
||||
return {"echoed": True}
|
||||
|
||||
server.add_request_handler("myorg/echo", RequestParams, echo)
|
||||
born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
|
||||
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
|
||||
result = await client.send_raw_request("myorg/echo", None)
|
||||
assert result == {"echoed": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runner_middleware_short_circuit_on_a_wrong_version_spec_method_skips_the_sieve(server: SrvT):
|
||||
"""A server-tier middleware that returns without calling `call_next` for a
|
||||
|
|
@ -1205,3 +1201,194 @@ async def test_runner_exit_stack_fast_cleanup_completes_within_grace(
|
|||
await client.send_raw_request("tools/list", None)
|
||||
assert cleaned == [2, 1]
|
||||
assert "abandoning remaining callbacks" not in caplog.text
|
||||
|
||||
|
||||
# --- to_jsonrpc_response -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response():
|
||||
"""SDK-defined: a handler coroutine resolving to a result dict is wrapped as a
|
||||
`JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`."""
|
||||
|
||||
async def ok() -> dict[str, Any]:
|
||||
return {"k": "v"}
|
||||
|
||||
reply = await to_jsonrpc_response(7, ok())
|
||||
assert isinstance(reply, JSONRPCResponse)
|
||||
assert reply.id == 7
|
||||
assert reply.result == {"k": "v"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error():
|
||||
"""SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a
|
||||
`JSONRPCError` whose `error` carries the same code, message, and data."""
|
||||
|
||||
async def fail() -> dict[str, Any]:
|
||||
raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x")
|
||||
|
||||
reply = await to_jsonrpc_response("rid", fail())
|
||||
assert isinstance(reply, JSONRPCError)
|
||||
assert reply.id == "rid"
|
||||
assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params():
|
||||
"""SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is
|
||||
mapped to `INVALID_PARAMS` with a generic message (validator detail does not
|
||||
reach the wire)."""
|
||||
|
||||
async def fail() -> dict[str, Any]:
|
||||
Tool.model_validate({"name": 123}) # raises ValidationError
|
||||
raise NotImplementedError
|
||||
|
||||
reply = await to_jsonrpc_response(1, fail())
|
||||
assert isinstance(reply, JSONRPCError)
|
||||
assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""SDK-defined: an unmapped exception is logged server-side and surfaced as
|
||||
`INTERNAL_ERROR` with a generic message; the exception text never reaches the
|
||||
wire."""
|
||||
|
||||
async def fail() -> dict[str, Any]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
reply = await to_jsonrpc_response(1, fail())
|
||||
assert isinstance(reply, JSONRPCError)
|
||||
assert reply.error.code == INTERNAL_ERROR
|
||||
# Handler internals never reach the wire.
|
||||
assert "boom" not in reply.error.message
|
||||
assert "request handler raised" in caplog.text
|
||||
|
||||
|
||||
# --- aclose_shielded -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_aclose_shielded_runs_callbacks_under_outer_cancellation():
|
||||
"""The shield lets per-connection cleanup run even when the enclosing scope
|
||||
is being cancelled."""
|
||||
cleaned: list[int] = []
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
|
||||
async def _append() -> None:
|
||||
await anyio.sleep(0)
|
||||
cleaned.append(1)
|
||||
|
||||
conn.exit_stack.push_async_callback(_append)
|
||||
with anyio.CancelScope() as scope:
|
||||
scope.cancel()
|
||||
await aclose_shielded(conn)
|
||||
assert cleaned == [1]
|
||||
|
||||
|
||||
# --- serve_one / serve_connection ---------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubDispatchContext:
|
||||
"""Minimal `DispatchContext` for `serve_one` driver tests.
|
||||
|
||||
The modern entry hands a per-request context to `serve_one`; this stub
|
||||
satisfies the protocol structurally with no real back-channel.
|
||||
"""
|
||||
|
||||
request_id: int | str | None
|
||||
transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False))
|
||||
message_metadata: MessageMetadata = None
|
||||
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
||||
can_send_request: bool = False
|
||||
|
||||
async def send_raw_request(
|
||||
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def _append_async(dst: list[int], v: int) -> None:
|
||||
dst.append(v)
|
||||
|
||||
|
||||
_LIFESPAN: dict[str, Any] = {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT):
|
||||
"""The single-exchange driver: builds the kernel, runs `on_request` once,
|
||||
wraps via `to_jsonrpc_response`, and tears down `connection.exit_stack`."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
cleaned: list[int] = []
|
||||
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
|
||||
request = JSONRPCRequest(jsonrpc="2.0", id=9, method="tools/list", params=None)
|
||||
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(9), lifespan_state=_LIFESPAN)
|
||||
assert isinstance(reply, JSONRPCResponse)
|
||||
assert reply.id == 9
|
||||
assert reply.result["tools"][0]["name"] == "t"
|
||||
assert cleaned == [1]
|
||||
ctx = _seen_ctx[0]
|
||||
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT):
|
||||
"""SDK-defined: a kernel-produced error (here `METHOD_NOT_FOUND` for an
|
||||
unregistered method) is wrapped as a `JSONRPCError`, and the per-request
|
||||
exit stack is closed on the error path too."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
cleaned: list[int] = []
|
||||
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
|
||||
request = JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/list", params=None)
|
||||
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(2), lifespan_state=_LIFESPAN)
|
||||
assert isinstance(reply, JSONRPCError)
|
||||
assert reply.error.code == METHOD_NOT_FOUND
|
||||
assert cleaned == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: SrvT):
|
||||
"""`serve_one` builds the kernel over the entry's `Connection`; the kernel
|
||||
reads `connection.protocol_version` for the version gate. A `from_envelope`
|
||||
connection at a modern version rejects a method absent there."""
|
||||
conn = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None)
|
||||
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "info"})
|
||||
reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(1), lifespan_state=_LIFESPAN)
|
||||
assert isinstance(reply, JSONRPCError)
|
||||
assert reply.error.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: SrvT):
|
||||
"""The loop-mode driver: `serve_connection` builds the kernel, hands
|
||||
`on_request`/`on_notify` to `dispatcher.run()`, and `aclose_shielded`s the
|
||||
connection on the way out."""
|
||||
client, server_d, close = jsonrpc_pair()
|
||||
assert isinstance(client, JSONRPCDispatcher) and isinstance(server_d, JSONRPCDispatcher)
|
||||
conn = Connection.for_loop(server_d)
|
||||
cleaned: list[int] = []
|
||||
conn.exit_stack.push_async_callback(_append_async, cleaned, 1)
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
await tg.start(partial(serve_connection, server, server_d, connection=conn, lifespan_state=_LIFESPAN))
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("initialize", _initialize_params())
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
assert cleaned == []
|
||||
close()
|
||||
assert cleaned == [1]
|
||||
assert conn.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
assert conn.client_params is not None
|
||||
|
|
|
|||
|
|
@ -31,21 +31,20 @@ class _Lifespan:
|
|||
@pytest.mark.anyio
|
||||
async def test_context_exposes_lifespan_and_connection_and_forwards_base_context():
|
||||
captured: list[Context[_Lifespan]] = []
|
||||
conn = Connection.__new__(Connection) # placeholder until running_pair gives us the dispatcher
|
||||
conn_holder: list[Connection] = []
|
||||
|
||||
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn)
|
||||
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn_holder[0])
|
||||
captured.append(ctx)
|
||||
return {}
|
||||
|
||||
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
|
||||
# Now we have the server dispatcher; build the real Connection bound to it.
|
||||
conn.__init__(server, has_standalone_channel=True, session_id="sess-1")
|
||||
conn_holder.append(Connection.for_loop(server, session_id="sess-1"))
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("t", None)
|
||||
ctx = captured[0]
|
||||
assert ctx.lifespan.name == "app"
|
||||
assert ctx.connection is conn
|
||||
assert ctx.connection is conn_holder[0]
|
||||
assert ctx.transport.kind == "direct"
|
||||
assert ctx.can_send_request is True
|
||||
assert ctx.session_id == "sess-1"
|
||||
|
|
@ -58,9 +57,7 @@ async def test_context_log_sends_request_scoped_message_notification():
|
|||
_, c_notify = echo_handlers(crec)
|
||||
|
||||
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
ctx: Context[_Lifespan] = Context(
|
||||
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
|
||||
)
|
||||
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
|
||||
await ctx.log("debug", "hello") # pyright: ignore[reportDeprecated]
|
||||
return {}
|
||||
|
||||
|
|
@ -82,9 +79,7 @@ async def test_context_log_includes_logger_and_meta_when_supplied():
|
|||
_, c_notify = echo_handlers(crec)
|
||||
|
||||
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
ctx: Context[_Lifespan] = Context(
|
||||
dctx, lifespan=_Lifespan("app"), connection=Connection(dctx, has_standalone_channel=True)
|
||||
)
|
||||
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
|
||||
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"}) # pyright: ignore[reportDeprecated]
|
||||
return {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,38 @@
|
|||
"""Tests for `ServerSession`.
|
||||
|
||||
`ServerSession` is a thin proxy over a dispatcher and a `Connection`. Tested
|
||||
with a stub dispatcher so we can assert what reaches the wire (method, params,
|
||||
`CallOptions`, related-request-id) without standing up a full transport.
|
||||
`ServerSession` is a thin per-request proxy over two `Outbound` channels and a
|
||||
`Connection`. Tested with stub outbounds so we can assert what reaches the wire
|
||||
(method, params, `CallOptions`) and which channel it routed to, without standing
|
||||
up a transport.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mcp import types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.dispatcher import CallOptions, Outbound
|
||||
from mcp.shared.message import ServerMessageMetadata
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
SamplingCapability,
|
||||
SamplingToolsCapability,
|
||||
)
|
||||
|
||||
from .test_runner import connected_runner
|
||||
|
||||
|
||||
class StubDispatcher:
|
||||
class StubOutbound:
|
||||
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
|
||||
|
||||
def __init__(self, result: dict[str, Any] | None = None) -> None:
|
||||
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None, Any]] = []
|
||||
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
|
||||
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
self.result = result if result is not None else {}
|
||||
|
||||
async def send_raw_request(
|
||||
|
|
@ -43,40 +40,36 @@ class StubDispatcher:
|
|||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None = None,
|
||||
*,
|
||||
_related_request_id: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
self.requests.append((method, params, opts, _related_request_id))
|
||||
self.requests.append((method, params, opts))
|
||||
return self.result
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
raise NotImplementedError
|
||||
self.notifications.append((method, params))
|
||||
|
||||
|
||||
def _make_session(
|
||||
dispatcher: StubDispatcher,
|
||||
outbound: StubOutbound,
|
||||
*,
|
||||
capabilities: ClientCapabilities | None = None,
|
||||
has_standalone_channel: bool = True,
|
||||
protocol_version: str | None = None,
|
||||
protocol_version: str = LATEST_PROTOCOL_VERSION,
|
||||
) -> ServerSession:
|
||||
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
|
||||
conn.protocol_version = protocol_version
|
||||
if capabilities is not None:
|
||||
conn.client_params = InitializeRequestParams(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
capabilities=capabilities,
|
||||
client_info=Implementation(name="c", version="0"),
|
||||
)
|
||||
# cast: `ServerSession` is typed to take `JSONRPCDispatcher` but only ever
|
||||
# calls `send_raw_request` / `notify`, so the stub is structurally sufficient.
|
||||
return ServerSession(cast("JSONRPCDispatcher[Any]", dispatcher), conn)
|
||||
"""Single-channel session: the stub is both request and standalone outbound."""
|
||||
client_info = Implementation(name="c", version="0") if capabilities is not None else None
|
||||
conn = Connection.from_envelope(protocol_version, client_info, capabilities, outbound=outbound)
|
||||
return ServerSession(outbound, conn)
|
||||
|
||||
|
||||
def _two_channel_session(request_ch: Outbound, standalone_ch: Outbound) -> ServerSession:
|
||||
"""Distinct request/standalone outbounds so routing assertions can tell the channels apart."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None, outbound=standalone_ch)
|
||||
return ServerSession(request_ch, conn)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_forwards_timeout_and_progress_callback_as_call_options():
|
||||
dispatcher = StubDispatcher(result={"roots": []})
|
||||
session = _make_session(dispatcher)
|
||||
outbound = StubOutbound(result={"roots": []})
|
||||
session = _make_session(outbound)
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
raise NotImplementedError
|
||||
|
|
@ -85,57 +78,78 @@ async def test_send_request_forwards_timeout_and_progress_callback_as_call_optio
|
|||
types.ListRootsRequest(),
|
||||
types.ListRootsResult,
|
||||
request_read_timeout_seconds=2.5,
|
||||
metadata=ServerMessageMetadata(related_request_id=7),
|
||||
progress_callback=on_progress,
|
||||
)
|
||||
assert isinstance(result, types.ListRootsResult)
|
||||
method, _params, opts, related = dispatcher.requests[0]
|
||||
method, _params, opts = outbound.requests[0]
|
||||
assert method == "roots/list"
|
||||
assert opts == {"timeout": 2.5, "on_progress": on_progress}
|
||||
assert related == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_omits_call_options_when_none_given():
|
||||
dispatcher = StubDispatcher(result={"roots": []})
|
||||
session = _make_session(dispatcher)
|
||||
outbound = StubOutbound(result={"roots": []})
|
||||
session = _make_session(outbound)
|
||||
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
|
||||
_method, _params, opts, related = dispatcher.requests[0]
|
||||
_method, _params, opts = outbound.requests[0]
|
||||
assert opts is None
|
||||
assert related is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_timeout_zero_is_forwarded():
|
||||
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
|
||||
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
|
||||
dispatcher = StubDispatcher(result={})
|
||||
session = _make_session(dispatcher)
|
||||
semantics) and must reach the channel; only `None` means "no timeout"."""
|
||||
outbound = StubOutbound(result={})
|
||||
session = _make_session(outbound)
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
|
||||
assert dispatcher.requests[0][2] == {"timeout": 0.0}
|
||||
assert outbound.requests[0][2] == {"timeout": 0.0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_without_back_channel_or_related_id_fails_fast():
|
||||
"""No standalone channel and no related request to ride on: raise instead
|
||||
of parking forever on a response that cannot arrive."""
|
||||
dispatcher = StubDispatcher(result={})
|
||||
session = _make_session(dispatcher, has_standalone_channel=False)
|
||||
with pytest.raises(NoBackChannelError):
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult)
|
||||
assert dispatcher.requests == []
|
||||
# With a related request id the message rides that request's stream.
|
||||
await session.send_request(
|
||||
types.PingRequest(), types.EmptyResult, metadata=ServerMessageMetadata(related_request_id=3)
|
||||
async def test_send_request_without_related_id_routes_to_standalone_channel():
|
||||
"""SDK-defined: no `related_request_id` routes the request onto the connection's standalone channel."""
|
||||
request_ch = StubOutbound()
|
||||
standalone_ch = StubOutbound(result={"roots": []})
|
||||
session = _two_channel_session(request_ch, standalone_ch)
|
||||
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
|
||||
assert request_ch.requests == []
|
||||
assert standalone_ch.requests[0][0] == "roots/list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_with_related_id_routes_to_request_channel():
|
||||
"""SDK-defined: with `related_request_id` the request rides the per-request channel
|
||||
(the originating POST's response stream over streamable HTTP)."""
|
||||
request_ch = StubOutbound(result={"action": "cancel"})
|
||||
standalone_ch = StubOutbound()
|
||||
session = _two_channel_session(request_ch, standalone_ch)
|
||||
result = await session.send_request(
|
||||
types.ElicitRequest(params=types.ElicitRequestFormParams(message="q", requested_schema={})),
|
||||
types.ElicitResult,
|
||||
metadata=ServerMessageMetadata(related_request_id=7),
|
||||
)
|
||||
assert dispatcher.requests[0][3] == 3
|
||||
assert isinstance(result, types.ElicitResult)
|
||||
assert standalone_ch.requests == []
|
||||
assert request_ch.requests[0][0] == "elicitation/create"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_notification_routes_by_related_request_id():
|
||||
"""SDK-defined: notifications select channel by `related_request_id` exactly like requests."""
|
||||
request_ch = StubOutbound()
|
||||
standalone_ch = StubOutbound()
|
||||
session = _two_channel_session(request_ch, standalone_ch)
|
||||
await session.send_tool_list_changed()
|
||||
await session.send_progress_notification("tok", 0.5, related_request_id="req-1")
|
||||
assert [m for m, _ in standalone_ch.notifications] == ["notifications/tools/list_changed"]
|
||||
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_validates_the_client_result_against_the_surface_schema():
|
||||
"""A spec-method result that fails the per-version surface schema raises
|
||||
`ValidationError` even when the caller's `result_type` would accept it."""
|
||||
session = _make_session(StubDispatcher(result={"roots": "nope"}))
|
||||
session = _make_session(StubOutbound(result={"roots": "nope"}))
|
||||
with pytest.raises(ValidationError):
|
||||
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
|
||||
|
||||
|
|
@ -143,7 +157,7 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche
|
|||
@pytest.mark.anyio
|
||||
async def test_send_request_passes_a_spec_valid_client_result():
|
||||
"""A spec-valid client result passes the surface gate and parses to the typed model."""
|
||||
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
|
||||
session = _make_session(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
|
||||
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
|
||||
assert isinstance(result, types.ListRootsResult)
|
||||
assert str(result.roots[0].uri) == "file:///ws"
|
||||
|
|
@ -151,9 +165,9 @@ async def test_send_request_passes_a_spec_valid_client_result():
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
|
||||
"""Surface row absent for the negotiated version: gate is bypassed and only
|
||||
"""Surface row absent for the connection's version: gate is bypassed and only
|
||||
`result_type` validates."""
|
||||
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
|
||||
session = _make_session(StubOutbound(result={}), protocol_version=MODERN_PROTOCOL_VERSIONS[0])
|
||||
result = await session.send_request(types.PingRequest(), types.EmptyResult)
|
||||
assert isinstance(result, types.EmptyResult)
|
||||
|
||||
|
|
@ -163,7 +177,7 @@ async def test_send_request_validates_result_alias_only():
|
|||
"""Peer results validate alias-only; a snake_case key from the wire is
|
||||
ignored as extra, not populated by Python field name."""
|
||||
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
|
||||
session = _make_session(StubDispatcher(result=snake))
|
||||
session = _make_session(StubOutbound(result=snake))
|
||||
request = types.CreateMessageRequest(params=types.CreateMessageRequestParams(messages=[], max_tokens=1))
|
||||
result = await session.send_request(request, types.CreateMessageResult)
|
||||
assert result.stop_reason is None
|
||||
|
|
@ -171,9 +185,9 @@ async def test_send_request_validates_result_alias_only():
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_message_with_tools_returns_with_tools_result():
|
||||
dispatcher = StubDispatcher(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
|
||||
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
|
||||
session = _make_session(
|
||||
dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
|
||||
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
|
||||
)
|
||||
result = await session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
|
||||
|
|
@ -181,74 +195,22 @@ async def test_create_message_with_tools_returns_with_tools_result():
|
|||
tools=[types.Tool(name="t", input_schema={"type": "object"})],
|
||||
)
|
||||
assert isinstance(result, types.CreateMessageResultWithTools)
|
||||
method, params, _opts, _related = dispatcher.requests[0]
|
||||
method, params, _opts = outbound.requests[0]
|
||||
assert method == "sampling/createMessage"
|
||||
assert params is not None and params["tools"][0]["name"] == "t"
|
||||
|
||||
|
||||
def test_check_client_capability_delegates_to_connection():
|
||||
dispatcher = StubDispatcher()
|
||||
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
|
||||
outbound = StubOutbound()
|
||||
session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability()))
|
||||
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
|
||||
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
|
||||
|
||||
|
||||
def _runner_server(seen_versions: list[str | None]) -> Server[dict[str, Any]]:
|
||||
"""A lowlevel Server whose tools/list handler records `ctx.session.protocol_version`."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[dict[str, Any], Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
seen_versions.append(ctx.session.protocol_version)
|
||||
return types.ListToolsResult(tools=[])
|
||||
|
||||
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
|
||||
|
||||
|
||||
def _init_params(protocol_version: str) -> dict[str, Any]:
|
||||
return InitializeRequestParams(
|
||||
protocol_version=protocol_version,
|
||||
capabilities=ClientCapabilities(),
|
||||
client_info=Implementation(name="test-client", version="1.0"),
|
||||
).model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_protocol_version_is_none_before_initialize():
|
||||
"""No negotiated version is readable before the initialize handshake."""
|
||||
async with connected_runner(_runner_server([]), initialized=False) as (_client, runner):
|
||||
assert runner.session.protocol_version is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_protocol_version_is_negotiated_version_after_initialize():
|
||||
"""A supported requested version is echoed back and readable on the session,
|
||||
both directly and from inside a handler via `ctx.session`."""
|
||||
seen: list[str | None] = []
|
||||
async with connected_runner(_runner_server(seen), initialized=False) as (client, runner):
|
||||
result = await client.send_raw_request("initialize", _init_params("2025-03-26"))
|
||||
assert result["protocolVersion"] == "2025-03-26"
|
||||
assert runner.session.protocol_version == "2025-03-26"
|
||||
await client.send_raw_request("tools/list", None)
|
||||
assert seen == ["2025-03-26"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_protocol_version_reads_latest_when_requested_version_unsupported():
|
||||
"""An unsupported requested version negotiates down to LATEST_PROTOCOL_VERSION."""
|
||||
async with connected_runner(_runner_server([]), initialized=False) as (client, runner):
|
||||
result = await client.send_raw_request("initialize", _init_params("1999-01-01"))
|
||||
assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION
|
||||
assert runner.session.protocol_version == LATEST_PROTOCOL_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_protocol_version_is_none_on_stateless_connection():
|
||||
"""Stateless connections never see a handshake: requests flow, but the
|
||||
negotiated version legitimately stays None."""
|
||||
seen: list[str | None] = []
|
||||
async with connected_runner(_runner_server(seen), initialized=False, stateless=True) as (client, runner):
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result == {"tools": []}
|
||||
assert seen == [None]
|
||||
assert runner.session.protocol_version is None
|
||||
def test_protocol_version_proxies_connection():
|
||||
"""SDK-defined: `session.protocol_version` reads through to the held `Connection`."""
|
||||
_ARBITRARY_VERSION = "sentinel-version" # identity-only: any string the connection holds
|
||||
conn = Connection.from_envelope(_ARBITRARY_VERSION, None, None)
|
||||
session = ServerSession(StubOutbound(), conn)
|
||||
assert session.protocol_version == _ARBITRARY_VERSION
|
||||
assert session.client_params is None
|
||||
|
|
|
|||
|
|
@ -1,187 +1,170 @@
|
|||
"""Tests for stateless HTTP mode limitations.
|
||||
"""Tests for the no-back-channel path (stateless HTTP).
|
||||
|
||||
Stateless HTTP mode does not support server-to-client requests because there
|
||||
is no persistent connection for bidirectional communication. These tests verify
|
||||
that appropriate errors are raised when attempting to use unsupported features.
|
||||
A `Connection.from_envelope(...)` connection installs the no-channel sentinel
|
||||
as its standalone outbound, so server-to-client requests with no related
|
||||
request to ride on raise `NoBackChannelError` from the channel itself.
|
||||
|
||||
See: https://github.com/modelcontextprotocol/python-sdk/issues/1097
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import JSONRPCRequest, JSONRPCResponse, ListToolsResult, PaginatedRequestParams
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||
|
||||
|
||||
def _make_session(*, stateless: bool) -> ServerSession:
|
||||
"""A `ServerSession` with a mock dispatcher; the stateless guard fires before any send."""
|
||||
return ServerSession(
|
||||
Mock(spec=JSONRPCDispatcher),
|
||||
Connection(Mock(), has_standalone_channel=False),
|
||||
stateless=stateless,
|
||||
)
|
||||
class StubOutbound:
|
||||
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
|
||||
|
||||
def __init__(self, result: dict[str, Any] | None = None) -> None:
|
||||
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
|
||||
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
self.result = result if result is not None else {}
|
||||
|
||||
async def send_raw_request(
|
||||
self,
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.requests.append((method, params, opts))
|
||||
return self.result
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
self.notifications.append((method, params))
|
||||
|
||||
|
||||
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
|
||||
"""A session whose standalone channel is the connection's no-channel
|
||||
sentinel; the request channel is a working stub."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
assert conn.has_standalone_channel is False
|
||||
request = request_ch if request_ch is not None else StubOutbound()
|
||||
return ServerSession(request, conn), request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stateless_session() -> ServerSession:
|
||||
return _make_session(stateless=True)
|
||||
def no_channel_session() -> ServerSession:
|
||||
session, _ = _no_channel_session()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_roots_fails_in_stateless_mode(stateless_session: ServerSession):
|
||||
"""Test that list_roots raises StatelessModeNotSupported in stateless mode."""
|
||||
with pytest.raises(StatelessModeNotSupported, match="list_roots"):
|
||||
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
async def test_list_roots_raises_no_back_channel(no_channel_session: ServerSession):
|
||||
"""SDK-defined: `list_roots` has no `related_request_id` so it always rides
|
||||
the standalone channel, which raises here."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
assert exc.value.method == "roots/list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_message_fails_in_stateless_mode(stateless_session: ServerSession):
|
||||
"""Test that create_message raises StatelessModeNotSupported in stateless mode."""
|
||||
with pytest.raises(StatelessModeNotSupported, match="sampling"):
|
||||
await stateless_session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[
|
||||
types.SamplingMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text="hello"),
|
||||
)
|
||||
],
|
||||
async def test_send_ping_raises_no_back_channel(no_channel_session: ServerSession):
|
||||
"""SDK-defined: `send_ping` rides the standalone channel and raises when there is none."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.send_ping()
|
||||
assert exc.value.method == "ping"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_message_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
|
||||
"""SDK-defined: `create_message` without a related id rides the standalone channel and raises."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
|
||||
max_tokens=100,
|
||||
)
|
||||
assert exc.value.method == "sampling/createMessage"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicit_form_fails_in_stateless_mode(stateless_session: ServerSession):
|
||||
"""Test that elicit_form raises StatelessModeNotSupported in stateless mode."""
|
||||
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
|
||||
await stateless_session.elicit_form(
|
||||
message="Please provide input",
|
||||
requested_schema={"type": "object", "properties": {}},
|
||||
)
|
||||
async def test_elicit_form_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
|
||||
"""SDK-defined: `elicit_form` without a related id rides the standalone channel and raises."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.elicit_form(message="m", requested_schema={"type": "object", "properties": {}})
|
||||
assert exc.value.method == "elicitation/create"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicit_url_fails_in_stateless_mode(stateless_session: ServerSession):
|
||||
"""Test that elicit_url raises StatelessModeNotSupported in stateless mode."""
|
||||
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
|
||||
await stateless_session.elicit_url(
|
||||
message="Please authenticate",
|
||||
url="https://example.com/auth",
|
||||
elicitation_id="test-123",
|
||||
)
|
||||
async def test_elicit_url_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
|
||||
"""SDK-defined: `elicit_url` without a related id rides the standalone channel and raises."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.elicit_url(message="m", url="https://example.com/auth", elicitation_id="e-1")
|
||||
assert exc.value.method == "elicitation/create"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicit_deprecated_fails_in_stateless_mode(stateless_session: ServerSession):
|
||||
"""Test that the deprecated elicit method also fails in stateless mode."""
|
||||
with pytest.raises(StatelessModeNotSupported, match="elicitation"):
|
||||
await stateless_session.elicit(
|
||||
message="Please provide input",
|
||||
requested_schema={"type": "object", "properties": {}},
|
||||
)
|
||||
async def test_elicit_deprecated_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
|
||||
"""SDK-defined: the deprecated `elicit` alias routes the same as `elicit_form` and raises."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.elicit(message="m", requested_schema={"type": "object", "properties": {}})
|
||||
assert exc.value.method == "elicitation/create"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stateless_error_message_is_actionable(stateless_session: ServerSession):
|
||||
"""Test that the error message provides actionable guidance."""
|
||||
with pytest.raises(StatelessModeNotSupported) as exc_info:
|
||||
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should mention it's stateless mode
|
||||
assert "stateless HTTP mode" in error_message
|
||||
# Should explain why it doesn't work
|
||||
assert "server-to-client requests" in error_message
|
||||
# Should tell user how to fix it
|
||||
assert "stateless_http=False" in error_message
|
||||
async def test_send_request_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
|
||||
"""SDK-defined: the generic `send_request` path with no metadata routes standalone and raises."""
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await no_channel_session.send_request(types.ListRootsRequest(), types.ListRootsResult)
|
||||
assert exc.value.method == "roots/list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_exception_has_method_attribute(stateless_session: ServerSession):
|
||||
"""Test that the exception has a method attribute for programmatic access."""
|
||||
with pytest.raises(StatelessModeNotSupported) as exc_info:
|
||||
await stateless_session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert exc_info.value.method == "list_roots"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stateful_session() -> ServerSession:
|
||||
return _make_session(stateless=False)
|
||||
async def test_elicit_form_with_related_id_rides_the_request_channel():
|
||||
"""SDK-defined: with a related request the message rides the per-request
|
||||
channel, so the no-channel standalone is never touched and the call succeeds."""
|
||||
session, request_ch = _no_channel_session(StubOutbound(result={"action": "cancel"}))
|
||||
result = await session.elicit_form(
|
||||
message="m", requested_schema={"type": "object", "properties": {}}, related_request_id=3
|
||||
)
|
||||
assert isinstance(result, types.ElicitResult)
|
||||
assert request_ch.requests[0][0] == "elicitation/create"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stateful_mode_does_not_raise_stateless_error(
|
||||
stateful_session: ServerSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Test that StatelessModeNotSupported is not raised in stateful mode.
|
||||
async def test_send_log_message_with_related_id_rides_the_request_channel():
|
||||
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
|
||||
rides the per-request channel, so it is delivered even with no standalone back-channel."""
|
||||
session, request_ch = _no_channel_session()
|
||||
await session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info", data="hello", logger="test", related_request_id=3
|
||||
)
|
||||
assert request_ch.notifications == [("notifications/message", {"level": "info", "data": "hello", "logger": "test"})]
|
||||
|
||||
We mock send_request to avoid blocking on I/O while still verifying
|
||||
that the stateless check passes.
|
||||
"""
|
||||
send_request_called = False
|
||||
|
||||
async def mock_send_request(*_: Any, **__: Any) -> types.ListRootsResult:
|
||||
nonlocal send_request_called
|
||||
send_request_called = True
|
||||
return types.ListRootsResult(roots=[])
|
||||
@pytest.mark.anyio
|
||||
async def test_unrelated_notification_is_dropped_silently():
|
||||
"""SDK-defined: notifications on the no-channel standalone are best-effort — dropped, never raised."""
|
||||
session, request_ch = _no_channel_session()
|
||||
await session.send_tool_list_changed()
|
||||
assert request_ch.notifications == []
|
||||
|
||||
monkeypatch.setattr(stateful_session, "send_request", mock_send_request)
|
||||
|
||||
# This should NOT raise StatelessModeNotSupported
|
||||
result = await stateful_session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert send_request_called
|
||||
@pytest.mark.anyio
|
||||
async def test_loop_connection_outbound_does_not_raise_no_back_channel():
|
||||
"""SDK-defined: a `for_loop` connection holds a real outbound, so the
|
||||
standalone path reaches the channel rather than raising."""
|
||||
standalone = StubOutbound(result={"roots": []})
|
||||
conn = Connection.for_loop(standalone)
|
||||
assert conn.has_standalone_channel is True
|
||||
session = ServerSession(StubOutbound(), conn)
|
||||
result = await session.list_roots() # pyright: ignore[reportDeprecated]
|
||||
assert isinstance(result, types.ListRootsResult)
|
||||
assert standalone.requests[0][0] == "roots/list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_run_stateless_wires_no_standalone_channel():
|
||||
"""`Server.run(stateless=True)` must wire `Connection.has_standalone_channel=False`.
|
||||
|
||||
Stateless HTTP has no standalone GET stream, so server-initiated requests on
|
||||
the connection must fail fast with `NoBackChannelError` rather than write to
|
||||
a channel that will never deliver a response. The `ServerSession` typed
|
||||
helpers carry their own stateless guard (tested above); this pins the
|
||||
`Connection` wiring that `Server.run` produces.
|
||||
"""
|
||||
captured: list[Connection] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
# `ServerRequestContext` doesn't expose `connection` directly yet (it
|
||||
# will after the Context rework); reach it via the session for now.
|
||||
captured.append(ctx.session._connection) # pyright: ignore[reportPrivateUsage]
|
||||
return ListToolsResult(tools=[])
|
||||
|
||||
server: Server[Any] = Server("test", on_list_tools=list_tools)
|
||||
|
||||
to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10)
|
||||
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
|
||||
|
||||
async def run_server() -> None:
|
||||
await server.run(server_read, server_write, server.create_initialization_options(), stateless=True)
|
||||
|
||||
async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server:
|
||||
tg.start_soon(run_server)
|
||||
# stateless=True skips the init gate, so tools/list routes immediately.
|
||||
await to_server.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list")))
|
||||
with anyio.fail_after(5):
|
||||
response = (await from_server.receive()).message
|
||||
assert isinstance(response, JSONRPCResponse)
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
assert len(captured) == 1
|
||||
conn = captured[0]
|
||||
assert conn.has_standalone_channel is False
|
||||
with pytest.raises(NoBackChannelError):
|
||||
async def test_from_envelope_connection_ping_raises_no_back_channel():
|
||||
"""SDK-defined: `Connection`'s own helpers route through the same sentinel,
|
||||
so `ping` on a `from_envelope` connection raises."""
|
||||
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
|
||||
with pytest.raises(NoBackChannelError) as exc:
|
||||
await conn.ping()
|
||||
assert exc.value.method == "ping"
|
||||
|
|
|
|||
|
|
@ -103,11 +103,12 @@ async def running_manager():
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
|
||||
manager, app = running_manager
|
||||
manager, _app = running_manager
|
||||
|
||||
mock_mcp_run = AsyncMock(return_value=None)
|
||||
# This will be called by StreamableHTTPSessionManager's run_server -> self.app.run
|
||||
app.run = mock_mcp_run
|
||||
# The manager's `run_server` task drives `serve_loop` directly (the manager
|
||||
# owns lifespan); patch that seam so the loop returns immediately and we
|
||||
# can observe the cleanup that follows.
|
||||
mock_serve = AsyncMock(return_value=None)
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
|
|
@ -125,7 +126,8 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
|
|||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
# Trigger session creation
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
|
||||
# Extract session ID from response headers
|
||||
session_id = None
|
||||
|
|
@ -140,10 +142,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
|
|||
|
||||
assert session_id is not None, "Session ID not found in response headers"
|
||||
|
||||
# Ensure MCPServer.run was called
|
||||
mock_mcp_run.assert_called_once()
|
||||
mock_serve.assert_called_once()
|
||||
|
||||
# At this point, mock_mcp_run has completed, and the finally block in
|
||||
# At this point, mock_serve has completed, and the finally block in
|
||||
# StreamableHTTPSessionManager's run_server should have executed.
|
||||
|
||||
# To ensure the task spawned by handle_request finishes and cleanup occurs:
|
||||
|
|
@ -158,10 +159,9 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]):
|
||||
manager, app = running_manager
|
||||
manager, _app = running_manager
|
||||
|
||||
mock_mcp_run = AsyncMock(side_effect=TestException("Simulated crash"))
|
||||
app.run = mock_mcp_run
|
||||
mock_serve = AsyncMock(side_effect=TestException("Simulated crash"))
|
||||
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
|
|
@ -184,7 +184,8 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
|
|||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
# Trigger session creation
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
|
||||
session_id = None
|
||||
for msg in sent_messages: # pragma: no branch
|
||||
|
|
@ -198,7 +199,7 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
|
|||
|
||||
assert session_id is not None, "Session ID not found in response headers"
|
||||
|
||||
mock_mcp_run.assert_called_once()
|
||||
mock_serve.assert_called_once()
|
||||
|
||||
# Give other tasks a chance to run to ensure the finally block executes
|
||||
await anyio.sleep(0.01)
|
||||
|
|
@ -229,9 +230,6 @@ async def test_stateless_requests_memory_cleanup():
|
|||
|
||||
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
|
||||
async with manager.run():
|
||||
# Mock app.run to complete immediately
|
||||
app.run = AsyncMock(return_value=None)
|
||||
|
||||
# Send a simple request
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -2,61 +2,38 @@
|
|||
|
||||
The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins
|
||||
the wire contract end to end; these tests cover the module's internal seams directly --
|
||||
the closed back-channel on the dispatcher and dispatch context, the exception-to-error
|
||||
mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``.
|
||||
the closed back-channel on the dispatch context, and the request-validation ladder in
|
||||
``handle_modern_request``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
import mcp.server._streamable_http_modern as modern
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server._streamable_http_modern import (
|
||||
SingleExchangeDispatcher,
|
||||
_SingleExchangeDispatchContext,
|
||||
handle_modern_request,
|
||||
)
|
||||
from mcp.server import Server, ServerRequestContext, runner
|
||||
from mcp.server._streamable_http_modern import _SingleExchangeDispatchContext, handle_modern_request
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.dispatcher import DispatchContext
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest, ListToolsResult, PaginatedRequestParams
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
INVALID_REQUEST,
|
||||
PARSE_ERROR,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _request() -> Request:
|
||||
return Request({"type": "http", "method": "POST", "headers": []})
|
||||
|
||||
|
||||
async def test_single_exchange_dispatcher_has_no_back_channel_and_is_never_driven() -> None:
|
||||
"""The dispatcher refuses server-initiated requests, drops notifications, and is not run-driven.
|
||||
|
||||
A 2026-07-28 POST has no channel for the server to push to the client, and ``ServerRunner``
|
||||
never calls ``run()`` on this dispatcher -- ``handle()`` is invoked directly per request.
|
||||
"""
|
||||
dispatcher = SingleExchangeDispatcher(_request())
|
||||
with pytest.raises(NoBackChannelError):
|
||||
await dispatcher.send_raw_request("sampling/createMessage", None)
|
||||
assert await dispatcher.notify("notifications/message", None) is None
|
||||
|
||||
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
raise AssertionError("unreachable") # pragma: no cover
|
||||
|
||||
async def on_notify(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> None:
|
||||
raise AssertionError("unreachable") # pragma: no cover
|
||||
|
||||
with pytest.raises(RuntimeError, match="never driven"):
|
||||
await dispatcher.run(on_request, on_notify)
|
||||
|
||||
|
||||
async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
|
||||
"""The per-request dispatch context refuses server-initiated requests and drops notify/progress."""
|
||||
dctx = _SingleExchangeDispatchContext(
|
||||
|
|
@ -71,41 +48,49 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
|
|||
assert await dctx.progress(0.5, total=1.0, message="half") is None
|
||||
|
||||
|
||||
async def test_handle_maps_validation_error_to_invalid_params() -> None:
|
||||
"""A handler raising ``ValidationError`` is mapped to a ``-32602`` JSON-RPC error.
|
||||
|
||||
Mirrors ``JSONRPCDispatcher``'s exception-to-wire boundary: a Pydantic validation failure
|
||||
inside the handler becomes ``INVALID_PARAMS`` rather than the generic internal error.
|
||||
"""
|
||||
|
||||
async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
JSONRPCRequest.model_validate({}) # raises ValidationError
|
||||
raise AssertionError("unreachable") # pragma: no cover
|
||||
|
||||
dispatcher = SingleExchangeDispatcher(_request())
|
||||
msg = await dispatcher.handle(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={}), on_request)
|
||||
assert isinstance(msg, JSONRPCError)
|
||||
assert msg.id == 7
|
||||
assert msg.error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient:
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await handle_modern_request(server, security_settings, "2026-07-28", scope, receive, send)
|
||||
async with server.lifespan(server) as lifespan_state:
|
||||
await handle_modern_request(server, security_settings, lifespan_state, scope, receive, send)
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]},
|
||||
)
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_non_post_with_405() -> None:
|
||||
"""A GET on the 2026-07-28 entry is answered with 405 before any body is read."""
|
||||
async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_header() -> None:
|
||||
"""SDK-defined: a GET on the modern entry is an HTTP-verb mismatch — 405 Method Not
|
||||
Allowed with ``Allow: POST`` per RFC 9110. This is HTTP-layer (before JSON-RPC parsing)
|
||||
so there is no JSON-RPC body."""
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.get("/mcp")
|
||||
assert response.status_code == 405
|
||||
assert response.headers["allow"] == "POST"
|
||||
assert response.content == b""
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
|
||||
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
|
||||
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
|
||||
which is for malformed JSON."""
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.post(
|
||||
"/mcp",
|
||||
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["code"] == INVALID_REQUEST
|
||||
|
||||
|
||||
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:
|
||||
"""A POST whose body is not a valid ``JSONRPCRequest`` returns 400 with ``-32700``."""
|
||||
"""An unparseable POST body yields HTTP 400 with a ``PARSE_ERROR`` JSON-RPC error envelope.
|
||||
|
||||
SDK-defined: the 400 status comes from the SDK's error-code→HTTP-status table; spec-mandated: the
|
||||
body is a full JSON-RPC error object with ``id: null`` and code ``-32700``.
|
||||
"""
|
||||
async with _asgi_client(Server("test")) as http:
|
||||
response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"})
|
||||
assert response.status_code == 400
|
||||
|
|
@ -113,7 +98,7 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -
|
|||
assert response.json() == {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {"code": PARSE_ERROR, "message": "Parse error", "data": None},
|
||||
"error": {"code": PARSE_ERROR, "message": "Parse error"},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -129,20 +114,45 @@ async def test_handle_modern_request_returns_transport_security_error_response()
|
|||
def _list_tools_body() -> dict[str, Any]:
|
||||
"""A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope."""
|
||||
meta = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0],
|
||||
CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"},
|
||||
CLIENT_CAPABILITIES_META_KEY: {},
|
||||
}
|
||||
return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}}
|
||||
|
||||
|
||||
async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None:
|
||||
"""SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied —
|
||||
the request still routes (200 + result) and the handler observes ``client_params is None``
|
||||
rather than the request being rejected at the validation ladder. A non-spec method is
|
||||
used so the kernel's per-method params validation does not re-reject the envelope."""
|
||||
seen: list[object] = []
|
||||
|
||||
async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> dict[str, Any]:
|
||||
seen.append(ctx.session.client_params)
|
||||
return {"ok": True}
|
||||
|
||||
server: Server[Any] = Server("test")
|
||||
server.add_request_handler("custom/greet", PaginatedRequestParams, greet)
|
||||
|
||||
body = _list_tools_body()
|
||||
body["method"] = "custom/greet"
|
||||
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
|
||||
async with _asgi_client(server) as http:
|
||||
response = await http.post("/mcp", json=body, headers={"content-type": "application/json"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"] == {"ok": True}
|
||||
assert seen == [None]
|
||||
|
||||
|
||||
async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A raising ``connection.exit_stack`` callback is logged and swallowed; the computed result still ships.
|
||||
|
||||
The exit-stack guard mirrors ``ServerRunner.run``: cleanup runs in a ``finally`` after the
|
||||
handler, and an exception there must not displace the JSON-RPC response that was already built.
|
||||
The exit-stack guard is `aclose_shielded`: cleanup runs in `serve_one`'s ``finally`` after
|
||||
the handler, and an exception there must not displace the JSON-RPC response that was already
|
||||
built.
|
||||
"""
|
||||
|
||||
async def boom() -> None:
|
||||
|
|
@ -152,7 +162,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_rais
|
|||
ctx.session._connection.exit_stack.push_async_callback(boom)
|
||||
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger=modern.__name__):
|
||||
with caplog.at_level(logging.ERROR, logger=runner.__name__):
|
||||
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
|
||||
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
|
||||
|
||||
|
|
@ -170,7 +180,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
|
|||
blocker at its first checkpoint, the abandonment warning is logged, and the JSON-RPC response
|
||||
that was built before cleanup is sent unchanged.
|
||||
"""
|
||||
monkeypatch.setattr(modern, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
|
||||
monkeypatch.setattr(runner, "_EXIT_STACK_CLOSE_TIMEOUT", 0)
|
||||
|
||||
async def block() -> None:
|
||||
await anyio.Event().wait()
|
||||
|
|
@ -180,7 +190,7 @@ async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hang
|
|||
ctx.session._connection.exit_stack.push_async_callback(block)
|
||||
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
|
||||
|
||||
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=modern.__name__):
|
||||
with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=runner.__name__):
|
||||
async with _asgi_client(Server("test", on_list_tools=list_tools)) as http:
|
||||
response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"})
|
||||
# coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there);
|
||||
|
|
|
|||
221
tests/shared/test_inbound.py
Normal file
221
tests/shared/test_inbound.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""Pure-function tests of :mod:`mcp.shared.inbound`.
|
||||
|
||||
Independent verifier of the classifier: every ladder rung is exercised
|
||||
pass+fail with no ``mcp.server`` / transport imports and no inlined error-code
|
||||
or protocol-version literals — all facts are imported from their one source.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.shared.inbound import (
|
||||
ERROR_CODE_HTTP_STATUS,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
InboundLadderRejection,
|
||||
InboundModernRoute,
|
||||
classify_inbound_request,
|
||||
)
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
)
|
||||
from mcp.types.jsonrpc import (
|
||||
HEADER_MISMATCH,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
PARSE_ERROR,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
|
||||
MODERN = MODERN_PROTOCOL_VERSIONS[0]
|
||||
"""The modern protocol-version string, read from the registry — never inlined here."""
|
||||
|
||||
CLIENT_INFO = {"name": "t", "version": "0"}
|
||||
CLIENT_CAPS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def envelope(
|
||||
method: str = "tools/list",
|
||||
*,
|
||||
version: str = MODERN,
|
||||
drop: frozenset[str] = frozenset(),
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-RPC body carrying a complete modern ``_meta`` envelope.
|
||||
|
||||
``drop`` removes named envelope keys so rung-1 failures are driven from one
|
||||
table instead of repeating reserved-key constants per call site.
|
||||
"""
|
||||
meta: dict[str, Any] = {
|
||||
PROTOCOL_VERSION_META_KEY: version,
|
||||
CLIENT_INFO_META_KEY: CLIENT_INFO,
|
||||
CLIENT_CAPABILITIES_META_KEY: CLIENT_CAPS,
|
||||
}
|
||||
for key in drop:
|
||||
del meta[key]
|
||||
return {"jsonrpc": "2.0", "id": 1, "method": method, "params": {"_meta": meta}}
|
||||
|
||||
|
||||
def assert_rejected(result: object, code: int) -> InboundLadderRejection:
|
||||
assert isinstance(result, InboundLadderRejection)
|
||||
assert result.code == code
|
||||
return result
|
||||
|
||||
|
||||
# --- rung 1: envelope-three-keys -----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"),
|
||||
pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"),
|
||||
pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"),
|
||||
pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"),
|
||||
],
|
||||
)
|
||||
def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None:
|
||||
"""Spec-mandated: a modern request lacking any of the three reserved ``_meta`` keys is rejected INVALID_PARAMS."""
|
||||
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
assert rejection.data is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": None}, id="params-none"),
|
||||
pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": None}}, id="meta-none"),
|
||||
pytest.param(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": 0}}, id="meta-non-mapping"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
|
||||
"""Spec-mandated: non-mapping ``params`` / ``_meta`` cannot carry the envelope and reject INVALID_PARAMS."""
|
||||
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
|
||||
|
||||
# --- rung 2: protocol-version-supported ----------------------------------------
|
||||
|
||||
|
||||
def test_version_rung_rejects_unsupported_with_data_shape() -> None:
|
||||
"""Spec-mandated: an envelope version outside the modern set rejects with the ``supported``/``requested`` data."""
|
||||
rejection = assert_rejected(
|
||||
classify_inbound_request(envelope(version=LATEST_PROTOCOL_VERSION)),
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
assert rejection.data == {
|
||||
"supported": list(MODERN_PROTOCOL_VERSIONS),
|
||||
"requested": LATEST_PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def test_version_rung_data_reflects_supplied_supported_list() -> None:
|
||||
"""SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes."""
|
||||
custom = (LATEST_PROTOCOL_VERSION,)
|
||||
rejection = assert_rejected(
|
||||
classify_inbound_request(envelope(), supported_modern_versions=custom),
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
assert rejection.data == {"supported": list(custom), "requested": MODERN}
|
||||
|
||||
|
||||
# --- rung 3: header ↔ envelope agreement ---------------------------------------
|
||||
|
||||
|
||||
def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None:
|
||||
"""SDK-defined: ``headers=None`` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds."""
|
||||
result = classify_inbound_request(envelope(), headers=None)
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_header_rung_passes_when_header_matches_envelope() -> None:
|
||||
"""Spec-mandated: an HTTP version header equal to the envelope version passes rung 3."""
|
||||
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}, id="mismatch"),
|
||||
pytest.param({}, id="header-absent"),
|
||||
],
|
||||
)
|
||||
def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
|
||||
"""Spec-mandated: an absent or mismatched HTTP version header rejects HEADER_MISMATCH."""
|
||||
assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
# --- all rungs pass ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_rungs_pass_yields_route() -> None:
|
||||
"""Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope."""
|
||||
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
assert result.protocol_version == MODERN
|
||||
assert result.client_info == CLIENT_INFO
|
||||
assert result.client_capabilities == CLIENT_CAPS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"])
|
||||
def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
|
||||
"""SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision."""
|
||||
result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
|
||||
assert isinstance(result, InboundModernRoute)
|
||||
|
||||
|
||||
def test_ladder_first_failure_wins() -> None:
|
||||
"""Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported
|
||||
would both fail; the header rung fires first so an inconsistent client is told it
|
||||
disagrees with itself rather than that its body version is unsupported."""
|
||||
body = envelope(version=LATEST_PROTOCOL_VERSION)
|
||||
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
|
||||
assert_rejected(result, HEADER_MISMATCH)
|
||||
|
||||
|
||||
# --- ERROR_CODE_HTTP_STATUS ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "status"),
|
||||
[
|
||||
(PARSE_ERROR, 400),
|
||||
(INVALID_REQUEST, 400),
|
||||
(INVALID_PARAMS, 400),
|
||||
(HEADER_MISMATCH, 400),
|
||||
(MISSING_REQUIRED_CLIENT_CAPABILITY, 400),
|
||||
(UNSUPPORTED_PROTOCOL_VERSION, 400),
|
||||
(METHOD_NOT_FOUND, 404),
|
||||
],
|
||||
)
|
||||
def test_error_code_http_status_table(code: int, status: int) -> None:
|
||||
"""SDK-defined: pins the JSON-RPC error code → HTTP status mapping the streamable transport reads."""
|
||||
assert ERROR_CODE_HTTP_STATUS[code] == status
|
||||
|
||||
|
||||
def test_error_code_http_status_covers_every_ladder_code() -> None:
|
||||
"""SDK-defined: every code the ladder can emit has an HTTP-status entry, so the transport never has to default."""
|
||||
ladder_codes = {INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION, HEADER_MISMATCH}
|
||||
assert ladder_codes <= ERROR_CODE_HTTP_STATUS.keys()
|
||||
|
||||
|
||||
# --- shape invariants ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_verdict_dataclasses_are_frozen() -> None:
|
||||
"""SDK-defined: both verdict dataclasses are frozen so a route/rejection cannot be mutated after classification."""
|
||||
route = classify_inbound_request(envelope())
|
||||
assert isinstance(route, InboundModernRoute)
|
||||
rejection = InboundLadderRejection(code=METHOD_NOT_FOUND, message="m")
|
||||
for verdict in (route, rejection):
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
setattr(verdict, "message", "mutated")
|
||||
|
|
@ -50,6 +50,8 @@ from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, Ses
|
|||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
DEFAULT_NEGOTIATED_VERSION,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
InitializeResult,
|
||||
|
|
@ -1045,8 +1047,10 @@ async def test_streamable_http_client_session_termination(basic_app: Starlette)
|
|||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
# Attempt to make a request after termination
|
||||
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await session.list_tools()
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert "terminated" in exc_info.value.error.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -1106,8 +1110,10 @@ async def test_streamable_http_client_session_termination_204(
|
|||
):
|
||||
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
|
||||
# Attempt to make a request after termination
|
||||
with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await session.list_tools()
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert "terminated" in exc_info.value.error.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -1388,7 +1394,7 @@ async def test_streamablehttp_stateless_ctx_protocol_version_tracks_the_header(
|
|||
assert response.status_code == 200
|
||||
echoed = json.loads(first_sse_data(response)["result"]["content"][0]["text"])
|
||||
assert echoed["protocol_version"] == expected
|
||||
assert echoed["session_protocol_version"] is None
|
||||
assert echoed["session_protocol_version"] == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -1503,7 +1509,8 @@ async def test_server_validates_protocol_version_header(basic_app: Starlette) ->
|
|||
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
|
||||
assert session_id is not None
|
||||
|
||||
# Test request with invalid protocol version (should fail)
|
||||
# An unrecognised header value routes to the modern entry, where the
|
||||
# validation ladder rejects an envelope-less body at rung 1.
|
||||
response = await client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
|
|
@ -1515,21 +1522,7 @@ async def test_server_validates_protocol_version_header(basic_app: Starlette) ->
|
|||
json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-2"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower()
|
||||
|
||||
# Test request with unsupported protocol version (should fail)
|
||||
response = await client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
MCP_SESSION_ID_HEADER: session_id,
|
||||
MCP_PROTOCOL_VERSION_HEADER: "1999-01-01", # Very old unsupported version
|
||||
},
|
||||
json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-3"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower()
|
||||
assert response.json()["error"]["code"] == INVALID_PARAMS
|
||||
|
||||
# Test request with valid protocol version (should succeed)
|
||||
negotiated_version = extract_protocol_version_from_sse(init_response)
|
||||
|
|
|
|||
Loading…
Reference in a new issue