Skip to content

Commit 2a2cacd

Browse files
committed
review: t27 PeerMixin->ClientPeerMixin; t28 malformed-initialize regression test
t27: rename PeerMixin/Peer -> ClientPeerMixin/ClientPeer (the contents are exactly the spec ServerRequest set, server->client; sibling TypedServerRequestMixin already encodes direction). Fix the stale module/ class docstring claiming Connection mixes it in (it does not). t28: re-cover the deleted tests/issues/test_malformed_input.py regression on the new path - initialize with params=None returns INVALID_PARAMS and the runner keeps serving. t13: keep `notify` (asymmetry justified; layer-consistent). t29: deletion safe (interaction test_progress.py + dispatcher units cover it).
1 parent 87fdbc4 commit 2a2cacd

8 files changed

Lines changed: 44 additions & 32 deletions

File tree

src/mcp/server/context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp.shared.context import BaseContext
1212
from mcp.shared.dispatcher import DispatchContext
1313
from mcp.shared.message import CloseSSEStreamCallback
14-
from mcp.shared.peer import Meta, PeerMixin
14+
from mcp.shared.peer import ClientPeerMixin, Meta
1515
from mcp.shared.transport_context import TransportContext
1616
from mcp.types import LoggingLevel, RequestId, RequestParamsMeta
1717

@@ -41,11 +41,11 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):
4141
LifespanT = TypeVar("LifespanT", default=Any, covariant=True)
4242

4343

44-
class Context(BaseContext[TransportContext], PeerMixin, TypedServerRequestMixin, Generic[LifespanT]):
44+
class Context(BaseContext[TransportContext], ClientPeerMixin, TypedServerRequestMixin, Generic[LifespanT]):
4545
"""Server-side per-request context.
4646
4747
Composes `BaseContext` (forwards to `DispatchContext`, satisfies `Outbound`),
48-
`PeerMixin` (kwarg-style `sample`/`elicit_*`/`list_roots`/`ping`),
48+
`ClientPeerMixin` (kwarg-style `sample`/`elicit_*`/`list_roots`/`ping`),
4949
and `TypedServerRequestMixin` (typed `send_request(req) -> Result`). Adds
5050
`lifespan` and `connection`.
5151

src/mcp/shared/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
back-channel (`send_raw_request`/`notify`), progress reporting, and the cancel
55
event. Adds `meta` (the inbound request's `_meta` field).
66
7-
Satisfies `Outbound`, so `PeerMixin` works on it (the server-side `Context`
7+
Satisfies `Outbound`, so `ClientPeerMixin` works on it (the server-side `Context`
88
mixes that in directly). Shared between client and server: the server's
99
`Context` extends this with `lifespan`/`connection`; `ClientContext` is just an
1010
alias.

src/mcp/shared/dispatcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class Outbound(Protocol):
8080
"""Anything that can send requests and notifications to the peer.
8181
8282
Both `Dispatcher` (top-level outbound) and `DispatchContext` (back-channel
83-
during an inbound request) extend this. The MCP type layer (`PeerMixin`,
83+
during an inbound request) extend this. The MCP type layer (`ClientPeerMixin`,
8484
`Connection`, `Context`) builds typed `send_request` / convenience methods
8585
on top of this raw channel.
8686
"""

src/mcp/shared/peer.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Typed MCP request sugar over an `Outbound`.
22
3-
`PeerMixin` defines the server-to-client request methods (sampling, elicitation,
4-
roots, ping) once. Any class that satisfies `Outbound` (i.e. has
3+
`ClientPeerMixin` defines the server-to-client request methods (sampling,
4+
elicitation, roots, ping) once. Any class that satisfies `Outbound` (i.e. has
55
`send_raw_request` and `notify`) can mix it in and get the typed methods for
6-
free - `Context`, `Connection`, `Client`, or the bare `Peer` wrapper below.
6+
free - `Context`, or the bare `ClientPeer` wrapper below.
77
88
The mixin does no capability gating: it builds the params, calls
99
`self.send_raw_request(method, params)`, and parses the result into the typed
@@ -32,7 +32,7 @@
3232
ToolChoice,
3333
)
3434

35-
__all__ = ["Meta", "Peer", "PeerMixin"]
35+
__all__ = ["ClientPeer", "ClientPeerMixin", "Meta"]
3636

3737
Meta = dict[str, Any]
3838
"""Type alias for the `_meta` field carried on request/notification params."""
@@ -41,7 +41,7 @@
4141
def dump_params(model: BaseModel | None, meta: Meta | None = None) -> dict[str, Any] | None:
4242
"""Serialize a params model to a wire dict, merging `meta` into `_meta`.
4343
44-
Shared by `PeerMixin`, `Connection`, and `TypedServerRequestMixin` so every
44+
Shared by `ClientPeerMixin`, `Connection`, and `TypedServerRequestMixin` so every
4545
typed convenience method gets the same `_meta` handling. `meta` keys take
4646
precedence over any `_meta` already present on the model.
4747
"""
@@ -52,7 +52,7 @@ def dump_params(model: BaseModel | None, meta: Meta | None = None) -> dict[str,
5252
return out
5353

5454

55-
class PeerMixin:
55+
class ClientPeerMixin:
5656
"""Typed server-to-client request methods.
5757
5858
Each method constrains `self` to `Outbound` so the mixin can be applied
@@ -193,11 +193,11 @@ async def ping(self: Outbound, *, meta: Meta | None = None, opts: CallOptions |
193193
await self.send_raw_request("ping", dump_params(None, meta), opts)
194194

195195

196-
class Peer(PeerMixin):
197-
"""Standalone wrapper that gives any `Outbound` the `PeerMixin` sugar.
196+
class ClientPeer(ClientPeerMixin):
197+
"""Standalone wrapper that gives any `Outbound` the `ClientPeerMixin` sugar.
198198
199-
`Context` and `Connection` mix `PeerMixin` in directly; use `Peer` when
200-
you have a bare dispatcher (or any `Outbound`) and want the typed methods
199+
`Context` mixes `ClientPeerMixin` in directly; use `ClientPeer` when you
200+
have a bare dispatcher (or any `Outbound`) and want the typed methods
201201
without writing your own host class.
202202
"""
203203

tests/server/test_runner.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,18 @@ async def test_runner_rejects_snake_case_initialize_params(server: SrvT):
207207
assert exc.value.error.code == INVALID_PARAMS
208208

209209

210+
@pytest.mark.anyio
211+
async def test_runner_initialize_with_absent_params_returns_invalid_params_and_stays_alive(server: SrvT):
212+
"""Re-covers what the old `tests/issues/test_malformed_input.py` pinned: a
213+
malformed `initialize` is rejected and the runner keeps serving."""
214+
async with connected_runner(server, initialized=False) as (client, _):
215+
with pytest.raises(MCPError) as exc:
216+
await client.send_raw_request("initialize", None)
217+
assert exc.value.error.code == INVALID_PARAMS
218+
result = await client.send_raw_request("initialize", _initialize_params())
219+
assert result["serverInfo"]["name"] == "test-server"
220+
221+
210222
@pytest.mark.anyio
211223
async def test_runner_rejects_snake_case_params_for_custom_handler(server: SrvT):
212224
"""Custom-method handlers (which skip the spec-method gate) still validate

tests/server/test_server_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for the server-side `Context`.
22
33
`Context` composes `BaseContext` (forwarding to a `DispatchContext`) with
4-
`PeerMixin` (typed sample/elicit/roots/ping) plus `lifespan` and `connection`.
4+
`ClientPeerMixin` (typed sample/elicit/roots/ping) plus `lifespan` and `connection`.
55
End-to-end tested over `DirectDispatcher`.
66
"""
77

tests/shared/test_context.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
`BaseContext` is composition over a `DispatchContext` - it forwards
44
`transport`/`cancel_requested`/`send_raw_request`/`notify`/`progress`
5-
and adds `meta`. It must satisfy `Outbound` so `PeerMixin` works on it.
5+
and adds `meta`. It must satisfy `Outbound` so `ClientPeerMixin` works on it.
66
"""
77

88
from collections.abc import Mapping
@@ -13,7 +13,7 @@
1313

1414
from mcp.shared.context import BaseContext
1515
from mcp.shared.dispatcher import DispatchContext
16-
from mcp.shared.peer import Peer
16+
from mcp.shared.peer import ClientPeer
1717
from mcp.shared.transport_context import TransportContext
1818

1919
from .conftest import direct_pair, jsonrpc_pair
@@ -104,11 +104,11 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] |
104104

105105
@pytest.mark.anyio
106106
async def test_base_context_satisfies_outbound_so_peer_mixin_works():
107-
"""Wrapping a BaseContext in Peer proves it satisfies Outbound structurally."""
107+
"""Wrapping a BaseContext in ClientPeer proves it satisfies Outbound structurally."""
108108

109109
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
110110
bctx = BaseContext(ctx)
111-
await Peer(bctx).ping()
111+
await ClientPeer(bctx).ping()
112112
return {}
113113

114114
crec = Recorder()

tests/shared/test_peer.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
"""Tests for `PeerMixin` and `Peer`.
1+
"""Tests for `ClientPeerMixin` and `ClientPeer`.
22
3-
Each PeerMixin method is tested by wrapping a `DirectDispatcher` in `Peer`,
3+
Each ClientPeerMixin method is tested by wrapping a `DirectDispatcher` in `ClientPeer`,
44
calling the typed method, and asserting (a) the right method+params went out
55
and (b) the return value is the typed result model.
66
"""
@@ -12,7 +12,7 @@
1212
import pytest
1313

1414
from mcp.shared.dispatcher import DispatchContext
15-
from mcp.shared.peer import Peer, dump_params
15+
from mcp.shared.peer import ClientPeer, dump_params
1616
from mcp.shared.transport_context import TransportContext
1717
from mcp.types import (
1818
CreateMessageResult,
@@ -44,7 +44,7 @@ async def on_request(self, ctx: DCtx, method: str, params: Mapping[str, Any] | N
4444
async def test_peer_sample_sends_create_message_and_returns_typed_result():
4545
rec = _Recorder({"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"})
4646
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
47-
peer = Peer(client)
47+
peer = ClientPeer(client)
4848
with anyio.fail_after(5):
4949
result = await peer.sample(
5050
[SamplingMessage(role="user", content=TextContent(type="text", text="hello"))],
@@ -64,7 +64,7 @@ async def test_peer_sample_validates_result_alias_only():
6464
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
6565
rec = _Recorder(snake)
6666
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
67-
peer = Peer(client)
67+
peer = ClientPeer(client)
6868
with anyio.fail_after(5):
6969
result = await peer.sample(
7070
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))], max_tokens=1
@@ -77,7 +77,7 @@ async def test_peer_sample_validates_result_alias_only():
7777
async def test_peer_sample_with_tools_returns_with_tools_result():
7878
rec = _Recorder({"role": "assistant", "content": [{"type": "text", "text": "x"}], "model": "m"})
7979
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
80-
peer = Peer(client)
80+
peer = ClientPeer(client)
8181
with anyio.fail_after(5):
8282
result = await peer.sample(
8383
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))],
@@ -94,7 +94,7 @@ async def test_peer_sample_with_tools_returns_with_tools_result():
9494
async def test_peer_elicit_form_sends_elicitation_create_with_form_params():
9595
rec = _Recorder({"action": "accept", "content": {"name": "Max"}})
9696
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
97-
peer = Peer(client)
97+
peer = ClientPeer(client)
9898
with anyio.fail_after(5):
9999
result = await peer.elicit_form("Your name?", requested_schema={"type": "object", "properties": {}})
100100
method, params = rec.seen[0]
@@ -108,7 +108,7 @@ async def test_peer_elicit_form_sends_elicitation_create_with_form_params():
108108
async def test_peer_elicit_url_sends_elicitation_create_with_url_params():
109109
rec = _Recorder({"action": "accept"})
110110
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
111-
peer = Peer(client)
111+
peer = ClientPeer(client)
112112
with anyio.fail_after(5):
113113
result = await peer.elicit_url("Auth needed", url="https://example.com/auth", elicitation_id="e1")
114114
method, params = rec.seen[0]
@@ -122,7 +122,7 @@ async def test_peer_elicit_url_sends_elicitation_create_with_url_params():
122122
async def test_peer_list_roots_sends_roots_list_and_returns_typed_result():
123123
rec = _Recorder({"roots": [{"uri": "file:///workspace"}]})
124124
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
125-
peer = Peer(client)
125+
peer = ClientPeer(client)
126126
with anyio.fail_after(5):
127127
result = await peer.list_roots()
128128
method, _ = rec.seen[0]
@@ -136,7 +136,7 @@ async def test_peer_list_roots_sends_roots_list_and_returns_typed_result():
136136
async def test_peer_list_roots_with_meta_sends_meta_in_params():
137137
rec = _Recorder({"roots": []})
138138
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
139-
peer = Peer(client)
139+
peer = ClientPeer(client)
140140
with anyio.fail_after(5):
141141
await peer.list_roots(meta={"traceId": "t1"})
142142
method, params = rec.seen[0]
@@ -164,15 +164,15 @@ async def send_raw_request(
164164
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
165165
sent.append((method, params))
166166

167-
await Peer(_Out()).notify("n", {"x": 1})
167+
await ClientPeer(_Out()).notify("n", {"x": 1})
168168
assert sent == [("n", {"x": 1})]
169169

170170

171171
@pytest.mark.anyio
172172
async def test_peer_ping_sends_ping_and_returns_none():
173173
rec = _Recorder({})
174174
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
175-
peer = Peer(client)
175+
peer = ClientPeer(client)
176176
with anyio.fail_after(5):
177177
result = await peer.ping()
178178
method, _ = rec.seen[0]

0 commit comments

Comments
 (0)