forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_subscriptions.py
More file actions
142 lines (117 loc) · 6.01 KB
/
Copy pathtest_subscriptions.py
File metadata and controls
142 lines (117 loc) · 6.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Client.listen stream endings against lowlevel servers over the connect matrix."""
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp import MCPError
from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("subscriptions:listen:client:graceful-close")
async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None:
"""`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
@requirement("subscriptions:listen:client:lost")
async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None:
"""Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=params.notifications,
_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id},
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
@requirement("protocol:request-id:caller-supplied")
async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None:
"""The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller
while the request is still in flight - the key the server stamps every frame with for demux.
The assertion runs inside the open stream: the ack has arrived but the listen request's
response has not, so the id cannot have come from a response.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
seen: list[types.RequestId] = []
async def recording_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
seen.append(ctx.request_id)
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=recording_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert seen == [sub.subscription_id]
stock.close()
async for _event in sub:
raise NotImplementedError # unreachable: nothing was published
@requirement("subscriptions:listen:client:concurrent-demux")
@requirement("protocol:request-id:caller-supplied")
async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None:
"""Two subscriptions opened concurrently each surface the honored filter of their own request:
ack frames route by subscription id, not broadcast to every open route.
The server gates both acks until both listen requests have arrived, so both client routes are
live and unacknowledged when the first ack lands - a client that broadcast subscription frames
would cross-pollute that ack into both handles.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
arrived: list[types.RequestId] = []
both_arrived = anyio.Event()
async def gated_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
arrived.append(ctx.request_id)
if len(arrived) == 2:
both_arrived.set()
with anyio.fail_after(10):
await both_arrived.wait()
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=gated_listen)
honored: dict[str, types.SubscriptionFilter] = {}
async with connect(server) as client:
async def open_tools() -> None:
async with client.listen(tools_list_changed=True) as sub:
honored["tools"] = sub.honored
async def open_prompts() -> None:
async with client.listen(prompts_list_changed=True) as sub:
honored["prompts"] = sub.honored
with anyio.fail_after(10):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(open_tools)
tg.start_soon(open_prompts)
assert honored == {
"tools": types.SubscriptionFilter(tools_list_changed=True),
"prompts": types.SubscriptionFilter(prompts_list_changed=True),
}
assert len(set(arrived)) == 2