Skip to content

Commit 44a1e83

Browse files
committed
Validate client results against the surface schema in server send_request
ServerSession.send_request and Connection.send_request now run the client's response through validate_client_result before parsing into the caller's result_type. KeyError (spec method without a row at the negotiated version, or a non-spec method on Connection) is tolerated and the existing model_validate proceeds; ValidationError propagates as today. Adds validate_client_result to types.methods and refactors parse_client_result to delegate its surface step to it.
1 parent b952bdf commit 44a1e83

6 files changed

Lines changed: 122 additions & 4 deletions

File tree

src/mcp/server/connection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
PingRequest,
3939
Request,
4040
)
41+
from mcp.types import methods as _methods
4142

4243
__all__ = ["Connection"]
4344

@@ -175,6 +176,13 @@ async def send_request(
175176
KeyError: `result_type` omitted for a non-spec request type.
176177
"""
177178
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
179+
# Literal fallback covers pre-handshake and stateless; matches runner.py.
180+
version = self.protocol_version or "2025-11-25"
181+
if req.method in _methods.MONOLITH_REQUESTS:
182+
try:
183+
_methods.validate_client_result(req.method, version, raw)
184+
except KeyError:
185+
pass
178186
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
179187
return cls.model_validate(raw, by_name=False)
180188

src/mcp/server/session.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
2121
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2222
from mcp.shared.message import ServerMessageMetadata
23+
from mcp.types import methods as _methods
2324

2425
__all__ = ["ServerSession"]
2526

@@ -96,6 +97,12 @@ async def send_request(
9697
result = await self._dispatcher.send_raw_request(
9798
data["method"], data.get("params"), opts or None, _related_request_id=related
9899
)
100+
# Literal fallback covers pre-handshake and stateless; matches runner.py.
101+
version = self.protocol_version or "2025-11-25"
102+
try:
103+
_methods.validate_client_result(request.method, version, result)
104+
except KeyError:
105+
pass
99106
return result_type.model_validate(result, by_name=False)
100107

101108
async def send_notification(

src/mcp/types/methods.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"serialize_server_result",
4242
"validate_client_notification",
4343
"validate_client_request",
44+
"validate_client_result",
4445
"validate_server_result",
4546
]
4647

@@ -652,6 +653,24 @@ def parse_server_result(
652653
return result
653654

654655

656+
def validate_client_result(
657+
method: str,
658+
version: str,
659+
data: Mapping[str, Any],
660+
*,
661+
surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = CLIENT_RESULTS,
662+
) -> None:
663+
"""Validate a client result against `surface` only.
664+
665+
Raises:
666+
ValueError: `version` is not a known protocol version.
667+
KeyError: `(method, version)` is not in `surface`.
668+
pydantic.ValidationError: result fails surface validation.
669+
"""
670+
_check_known_version(version)
671+
_adapter(surface[(method, version)]).validate_python(data, by_name=False)
672+
673+
655674
def parse_client_result(
656675
method: str,
657676
version: str,
@@ -675,7 +694,6 @@ def parse_client_result(
675694
pydantic.ValidationError: result fails surface or monolith validation.
676695
RuntimeError: surface matched but `method` has no monolith row.
677696
"""
678-
_check_known_version(version)
679-
_adapter(surface[(method, version)]).validate_python(data, by_name=False)
697+
validate_client_result(method, version, data, surface=surface)
680698
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
681699
return result

tests/server/test_connection.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99
import logging
1010
from collections.abc import Mapping
11-
from typing import Any
11+
from typing import Any, Literal
1212

1313
import anyio
1414
import pytest
15-
from pydantic import ValidationError
15+
from pydantic import BaseModel, ValidationError
1616

1717
from mcp.server.connection import Connection
1818
from mcp.shared.dispatcher import CallOptions
@@ -29,6 +29,8 @@
2929
ListRootsRequest,
3030
ListRootsResult,
3131
PingRequest,
32+
Request,
33+
RequestParams,
3234
RootsCapability,
3335
SamplingCapability,
3436
SamplingContextCapability,
@@ -144,6 +146,54 @@ async def test_connection_send_request_nonconforming_result_raises_validation_er
144146
await conn.send_request(ListRootsRequest())
145147

146148

149+
@pytest.mark.anyio
150+
async def test_send_request_validates_the_client_result_against_the_surface_schema():
151+
"""A spec-method result that fails the per-version surface schema raises
152+
`ValidationError` even when the caller's `result_type` would accept it."""
153+
conn = Connection(StubOutbound(result={"roots": "nope"}), has_standalone_channel=True)
154+
with pytest.raises(ValidationError):
155+
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
156+
157+
158+
@pytest.mark.anyio
159+
async def test_send_request_passes_a_spec_valid_client_result():
160+
"""A spec-valid client result passes the surface gate and parses to the typed model."""
161+
conn = Connection(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}), has_standalone_channel=True)
162+
conn.protocol_version = "2025-11-25"
163+
result = await conn.send_request(ListRootsRequest())
164+
assert isinstance(result, ListRootsResult)
165+
assert str(result.roots[0].uri) == "file:///ws"
166+
167+
168+
class _CustomRequest(Request[RequestParams | None, Literal["custom/echo"]]):
169+
method: Literal["custom/echo"] = "custom/echo"
170+
params: RequestParams | None = None
171+
172+
173+
class _CustomResult(BaseModel):
174+
value: int
175+
176+
177+
@pytest.mark.anyio
178+
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
179+
"""Surface row absent for the negotiated version: gate is bypassed and only
180+
the inferred result type validates."""
181+
conn = Connection(StubOutbound(result={}), has_standalone_channel=True)
182+
conn.protocol_version = "2026-07-28"
183+
result = await conn.send_request(PingRequest())
184+
assert isinstance(result, EmptyResult)
185+
186+
187+
@pytest.mark.anyio
188+
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
189+
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
190+
conn = Connection(StubOutbound(result={"value": 7}), has_standalone_channel=True)
191+
conn.protocol_version = "2025-11-25"
192+
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
193+
assert isinstance(result, _CustomResult)
194+
assert result.value == 7
195+
196+
147197
@pytest.mark.anyio
148198
async def test_connection_ping_sends_ping_on_standalone():
149199
out = StubOutbound()

tests/server/test_session.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any, cast
1010

1111
import pytest
12+
from pydantic import ValidationError
1213

1314
from mcp import types
1415
from mcp.server import Server, ServerRequestContext
@@ -57,8 +58,10 @@ def _make_session(
5758
*,
5859
capabilities: ClientCapabilities | None = None,
5960
has_standalone_channel: bool = True,
61+
protocol_version: str | None = None,
6062
) -> ServerSession:
6163
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
64+
conn.protocol_version = protocol_version
6265
if capabilities is not None:
6366
conn.client_params = InitializeRequestParams(
6467
protocol_version=LATEST_PROTOCOL_VERSION,
@@ -128,6 +131,33 @@ async def test_send_request_without_back_channel_or_related_id_fails_fast():
128131
assert dispatcher.requests[0][3] == 3
129132

130133

134+
@pytest.mark.anyio
135+
async def test_send_request_validates_the_client_result_against_the_surface_schema():
136+
"""A spec-method result that fails the per-version surface schema raises
137+
`ValidationError` even when the caller's `result_type` would accept it."""
138+
session = _make_session(StubDispatcher(result={"roots": "nope"}))
139+
with pytest.raises(ValidationError):
140+
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
141+
142+
143+
@pytest.mark.anyio
144+
async def test_send_request_passes_a_spec_valid_client_result():
145+
"""A spec-valid client result passes the surface gate and parses to the typed model."""
146+
session = _make_session(StubDispatcher(result={"roots": [{"uri": "file:///ws"}]}))
147+
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
148+
assert isinstance(result, types.ListRootsResult)
149+
assert str(result.roots[0].uri) == "file:///ws"
150+
151+
152+
@pytest.mark.anyio
153+
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
154+
"""Surface row absent for the negotiated version: gate is bypassed and only
155+
`result_type` validates."""
156+
session = _make_session(StubDispatcher(result={}), protocol_version="2026-07-28")
157+
result = await session.send_request(types.PingRequest(), types.EmptyResult)
158+
assert isinstance(result, types.EmptyResult)
159+
160+
131161
@pytest.mark.anyio
132162
async def test_send_request_validates_result_alias_only():
133163
"""Peer results validate alias-only; a snake_case key from the wire is

tests/types/test_methods.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,18 +732,23 @@ def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings():
732732
methods.validate_client_request("tools/call", "2025-11-25", {"name": "echo"})
733733
methods.validate_client_notification("notifications/cancelled", "2025-11-25", {"requestId": 1})
734734
methods.validate_server_result("tools/list", "2025-11-25", {"tools": []})
735+
methods.validate_client_result("roots/list", "2025-11-25", {"roots": []})
735736
with pytest.raises(KeyError):
736737
methods.validate_client_request("custom/greet", "2025-11-25", None)
737738
with pytest.raises(KeyError):
738739
methods.validate_client_notification("custom/ping", "2025-11-25", None)
739740
with pytest.raises(KeyError):
740741
methods.validate_server_result("custom/greet", "2025-11-25", {})
742+
with pytest.raises(KeyError):
743+
methods.validate_client_result("roots/list", "2026-07-28", {})
741744
with pytest.raises(pydantic.ValidationError):
742745
methods.validate_client_request("tools/call", "2025-11-25", None)
743746
with pytest.raises(pydantic.ValidationError):
744747
methods.validate_client_notification("notifications/progress", "2025-11-25", {"progressToken": []})
745748
with pytest.raises(pydantic.ValidationError):
746749
methods.validate_server_result("tools/list", "2025-11-25", {"tools": 42})
750+
with pytest.raises(pydantic.ValidationError):
751+
methods.validate_client_result("roots/list", "2025-11-25", {"roots": 42})
747752
with pytest.raises(ValueError):
748753
methods.validate_client_request("ping", "2099-01-01", None)
749754

0 commit comments

Comments
 (0)