Skip to content

Commit 50134cb

Browse files
committed
test: close remaining server-side coverage gaps; ServerSession.check_client_capability delegates
server/session.py: check_client_capability now delegates to Connection.check_capability instead of duplicating it. Connection's version gains the sampling.context/sampling.tools sub-checks and experimental value-equality so the delegation is complete. server/runner.py: otel_middleware sets jsonrpc.request.id unconditionally (DispatchMiddleware wraps on_request only; JSONRPCRequest.id is required, so the None guard was dead). tests/server/test_session.py: re-created for the new ServerSession(dispatcher, connection) shape - covers send_request timeout/progress_callback opts paths and the create_message tools branch. tests/server/test_server_context.py: assert Context.session_id and Context.headers.
1 parent 9f63603 commit 50134cb

7 files changed

Lines changed: 151 additions & 39 deletions

File tree

src/mcp/server/connection.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,19 @@ def check_capability(self, capability: ClientCapabilities) -> bool:
155155
return False
156156
if capability.roots.list_changed and not have.roots.list_changed:
157157
return False
158-
if capability.sampling is not None and have.sampling is None:
159-
return False
158+
if capability.sampling is not None:
159+
if have.sampling is None:
160+
return False
161+
if capability.sampling.context is not None and have.sampling.context is None:
162+
return False
163+
if capability.sampling.tools is not None and have.sampling.tools is None:
164+
return False
160165
if capability.elicitation is not None and have.elicitation is None:
161166
return False
162167
if capability.experimental is not None:
163168
if have.experimental is None:
164169
return False
165-
for k in capability.experimental:
166-
if k not in have.experimental:
170+
for k, v in capability.experimental.items():
171+
if k not in have.experimental or have.experimental[k] != v:
167172
return False
168173
return True

src/mcp/server/runner.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,8 @@ async def wrapped(
9494
case _:
9595
parent = None
9696
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
97-
attributes: dict[str, str | int] = {"mcp.method.name": method}
98-
if dctx.request_id is not None:
99-
attributes["jsonrpc.request.id"] = dctx.request_id
97+
# `otel_middleware` wraps `on_request` only, so `request_id` is always set.
98+
attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)}
10099
with otel_span(
101100
span_name,
102101
kind=SpanKind.SERVER,

src/mcp/server/session.py

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -91,36 +91,7 @@ async def send_notification(
9191

9292
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
9393
"""Check if the client supports a specific capability."""
94-
if self.client_params is None: # pragma: lax no cover
95-
return False
96-
97-
client_caps = self.client_params.capabilities
98-
99-
if capability.roots is not None: # pragma: lax no cover
100-
if client_caps.roots is None:
101-
return False
102-
if capability.roots.list_changed and not client_caps.roots.list_changed:
103-
return False
104-
105-
if capability.sampling is not None: # pragma: lax no cover
106-
if client_caps.sampling is None:
107-
return False
108-
if capability.sampling.context is not None and client_caps.sampling.context is None:
109-
return False
110-
if capability.sampling.tools is not None and client_caps.sampling.tools is None:
111-
return False
112-
113-
if capability.elicitation is not None and client_caps.elicitation is None: # pragma: lax no cover
114-
return False
115-
116-
if capability.experimental is not None: # pragma: lax no cover
117-
if client_caps.experimental is None:
118-
return False
119-
for exp_key, exp_value in capability.experimental.items():
120-
if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value:
121-
return False
122-
123-
return True
94+
return self._connection.check_capability(capability)
12495

12596
async def send_log_message(
12697
self,

tests/server/test_connection.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
PingRequest,
2929
RootsCapability,
3030
SamplingCapability,
31+
SamplingContextCapability,
32+
SamplingToolsCapability,
3133
)
3234

3335

@@ -194,8 +196,24 @@ def test_connection_check_capability_false_before_initialized():
194196
False,
195197
),
196198
(ClientCapabilities(sampling=None), ClientCapabilities(sampling=SamplingCapability()), False),
199+
(
200+
ClientCapabilities(sampling=SamplingCapability()),
201+
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
202+
False,
203+
),
204+
(
205+
ClientCapabilities(sampling=SamplingCapability()),
206+
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
207+
False,
208+
),
209+
(
210+
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
211+
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
212+
True,
213+
),
197214
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
198215
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
216+
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
199217
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"a": {}}), True),
200218
],
201219
)

tests/server/test_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
411411
assert span.name == "MCP handle tools/call mytool"
412412
assert span.attributes is not None
413413
assert span.attributes["mcp.method.name"] == "tools/call"
414-
assert isinstance(span.attributes["jsonrpc.request.id"], int)
414+
assert isinstance(span.attributes["jsonrpc.request.id"], str)
415415
assert span.status.status_code == StatusCode.UNSET
416416

417417

tests/server/test_server_context.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,16 @@ async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] |
4141

4242
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
4343
# Now we have the server dispatcher; build the real Connection bound to it.
44-
conn.__init__(server, has_standalone_channel=True)
44+
conn.__init__(server, has_standalone_channel=True, session_id="sess-1")
4545
with anyio.fail_after(5):
4646
await client.send_raw_request("t", None)
4747
ctx = captured[0]
4848
assert ctx.lifespan.name == "app"
4949
assert ctx.connection is conn
5050
assert ctx.transport.kind == "direct"
5151
assert ctx.can_send_request is True
52+
assert ctx.session_id == "sess-1"
53+
assert ctx.headers is None
5254

5355

5456
@pytest.mark.anyio

tests/server/test_session.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for `ServerSession`.
2+
3+
`ServerSession` is a thin proxy over a dispatcher and a `Connection`. Tested
4+
with a stub dispatcher so we can assert what reaches the wire (method, params,
5+
`CallOptions`, related-request-id) without standing up a full transport.
6+
"""
7+
8+
from collections.abc import Mapping
9+
from typing import Any, cast
10+
11+
import pytest
12+
13+
from mcp import types
14+
from mcp.server.connection import Connection
15+
from mcp.server.session import ServerSession
16+
from mcp.shared.dispatcher import CallOptions
17+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
18+
from mcp.shared.message import ServerMessageMetadata
19+
from mcp.types import (
20+
LATEST_PROTOCOL_VERSION,
21+
ClientCapabilities,
22+
Implementation,
23+
InitializeRequestParams,
24+
SamplingCapability,
25+
SamplingToolsCapability,
26+
)
27+
28+
29+
class StubDispatcher:
30+
"""Records `send_raw_request` / `notify` calls and returns a canned result."""
31+
32+
def __init__(self, result: dict[str, Any] | None = None) -> None:
33+
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None, Any]] = []
34+
self.result = result if result is not None else {}
35+
36+
async def send_raw_request(
37+
self,
38+
method: str,
39+
params: Mapping[str, Any] | None,
40+
opts: CallOptions | None = None,
41+
*,
42+
_related_request_id: Any = None,
43+
) -> dict[str, Any]:
44+
self.requests.append((method, params, opts, _related_request_id))
45+
return self.result
46+
47+
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
48+
raise NotImplementedError
49+
50+
51+
def _make_session(dispatcher: StubDispatcher, *, capabilities: ClientCapabilities | None = None) -> ServerSession:
52+
conn = Connection(dispatcher, has_standalone_channel=True)
53+
if capabilities is not None:
54+
conn.client_params = InitializeRequestParams(
55+
protocol_version=LATEST_PROTOCOL_VERSION,
56+
capabilities=capabilities,
57+
client_info=Implementation(name="c", version="0"),
58+
)
59+
# cast: `ServerSession` is typed to take `JSONRPCDispatcher` but only ever
60+
# calls `send_raw_request` / `notify`, so the stub is structurally sufficient.
61+
return ServerSession(cast("JSONRPCDispatcher[Any]", dispatcher), conn)
62+
63+
64+
@pytest.mark.anyio
65+
async def test_send_request_forwards_timeout_and_progress_callback_as_call_options():
66+
dispatcher = StubDispatcher(result={"roots": []})
67+
session = _make_session(dispatcher)
68+
69+
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
70+
raise NotImplementedError
71+
72+
result = await session.send_request(
73+
types.ListRootsRequest(),
74+
types.ListRootsResult,
75+
request_read_timeout_seconds=2.5,
76+
metadata=ServerMessageMetadata(related_request_id=7),
77+
progress_callback=on_progress,
78+
)
79+
assert isinstance(result, types.ListRootsResult)
80+
method, _params, opts, related = dispatcher.requests[0]
81+
assert method == "roots/list"
82+
assert opts == {"timeout": 2.5, "on_progress": on_progress}
83+
assert related == 7
84+
85+
86+
@pytest.mark.anyio
87+
async def test_send_request_omits_call_options_when_none_given():
88+
dispatcher = StubDispatcher(result={"roots": []})
89+
session = _make_session(dispatcher)
90+
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
91+
_method, _params, opts, related = dispatcher.requests[0]
92+
assert opts is None
93+
assert related is None
94+
95+
96+
@pytest.mark.anyio
97+
async def test_create_message_with_tools_returns_with_tools_result():
98+
dispatcher = StubDispatcher(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
99+
session = _make_session(
100+
dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
101+
)
102+
result = await session.create_message(
103+
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
104+
max_tokens=10,
105+
tools=[types.Tool(name="t", input_schema={"type": "object"})],
106+
)
107+
assert isinstance(result, types.CreateMessageResultWithTools)
108+
method, params, _opts, _related = dispatcher.requests[0]
109+
assert method == "sampling/createMessage"
110+
assert params is not None and params["tools"][0]["name"] == "t"
111+
112+
113+
def test_check_client_capability_delegates_to_connection():
114+
dispatcher = StubDispatcher()
115+
session = _make_session(dispatcher, capabilities=ClientCapabilities(sampling=SamplingCapability()))
116+
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
117+
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False

0 commit comments

Comments
 (0)