Add the client-side subscriptions/listen driver (#3047)
This commit is contained in:
parent
6d2e908f2b
commit
9bdc03d54e
4 changed files with 2587 additions and 295 deletions
|
|
@ -486,12 +486,6 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"id up front is the SDK surface that makes it satisfiable."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
deferred=(
|
||||
"No public API surface yet: the capability exists at the dispatcher seam "
|
||||
"(CallOptions['request_id'], unit-tested there), but ClientSession.send_request does not "
|
||||
"expose it. The public consumer arrives with the client-side listen driver (Client.listen), "
|
||||
"whose interaction tests will exercise it end to end."
|
||||
),
|
||||
),
|
||||
"protocol:notifications:no-response": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic#notifications",
|
||||
|
|
@ -1229,6 +1223,59 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
removed_in="2026-07-28",
|
||||
note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.",
|
||||
),
|
||||
"subscriptions:listen:client:honored-surfacing": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment",
|
||||
behavior=(
|
||||
"Entering Client.listen() waits for the server's acknowledgment and surfaces the honored "
|
||||
"filter subset on the handle, so the client can check it against what it requested (spec SHOULD)."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"subscriptions:listen:client:concurrent-demux": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#multiple-concurrent-subscriptions",
|
||||
behavior=(
|
||||
"Concurrently open subscriptions each surface their own acknowledgment: with both listen "
|
||||
"requests in flight before either ack arrives, each handle's honored filter is the subset "
|
||||
"for its own request, routed by subscription id rather than broadcast to every open route."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"subscriptions:listen:client:iteration": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"An open subscription is an async iterator of typed change events; delivered notifications "
|
||||
"still tee to message_handler so caching and observers keep working."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"subscriptions:listen:client:graceful-close": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#cancellation",
|
||||
behavior=(
|
||||
"The server's empty subscriptions/listen result (its deliberate close) ends iteration cleanly "
|
||||
"after buffered events drain; no exception is raised."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"subscriptions:listen:client:lost": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A listen stream that ends without the graceful result raises SubscriptionLost from iteration; "
|
||||
"there is no automatic re-listen."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"subscriptions:listen:client:era-guard": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"Client.listen() on a pre-2026 connection raises ListenNotSupportedError steering to "
|
||||
"subscribe_resource/message_handler instead of leaking a wire -32601."
|
||||
),
|
||||
removed_in="2026-07-28",
|
||||
note=(
|
||||
"removed_in scopes the matrix to the 2025 cells deliberately: the behavior under test is the "
|
||||
"guard on connections where the method does not exist."
|
||||
),
|
||||
),
|
||||
"resources:updated-notification": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
|
||||
behavior=(
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ async def test_list_resource_templates_returns_registered_templates(connect: Con
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
|
||||
@requirement("resources:subscribe")
|
||||
async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
|
||||
"""Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result."""
|
||||
|
|
@ -214,11 +215,12 @@ async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> N
|
|||
server = Server("library", on_subscribe_resource=subscribe_resource)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.subscribe_resource("file:///watched.txt")
|
||||
result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert result == snapshot(EmptyResult())
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
|
||||
@requirement("resources:subscribe:capability-required")
|
||||
async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None:
|
||||
"""Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND.
|
||||
|
|
@ -237,13 +239,14 @@ async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect
|
|||
|
||||
async with connect(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.subscribe_resource("file:///watched.txt")
|
||||
await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
|
||||
@requirement("resources:unsubscribe")
|
||||
async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
|
||||
"""Unsubscribing from a resource delivers the URI to the server's unsubscribe handler."""
|
||||
|
|
@ -255,7 +258,7 @@ async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) ->
|
|||
server = Server("library", on_unsubscribe_resource=unsubscribe_resource)
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.unsubscribe_resource("file:///watched.txt")
|
||||
result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert result == snapshot(EmptyResult())
|
||||
|
||||
|
|
|
|||
142
tests/interaction/lowlevel/test_subscriptions.py
Normal file
142
tests/interaction/lowlevel/test_subscriptions.py
Normal file
|
|
@ -0,0 +1,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
|
||||
62
tests/interaction/mcpserver/test_subscriptions.py
Normal file
62
tests/interaction/mcpserver/test_subscriptions.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Client.listen against MCPServer over the connect matrix (2026-07-28)."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _notebook() -> MCPServer:
|
||||
mcp = MCPServer("notebook")
|
||||
|
||||
@mcp.tool()
|
||||
async def touch_tools(ctx: Context) -> str:
|
||||
await ctx.notify_tools_changed()
|
||||
return "ok"
|
||||
|
||||
@mcp.tool()
|
||||
async def edit_note(name: str, ctx: Context) -> str:
|
||||
await ctx.notify_resource_updated(f"note://{name}")
|
||||
return "saved"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@requirement("subscriptions:listen:client:honored-surfacing")
|
||||
@requirement("subscriptions:listen:client:iteration")
|
||||
async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None:
|
||||
"""Entering waits for the ack (honored is set before any event); iteration yields
|
||||
only the typed event kinds this stream opted in to."""
|
||||
mcp = _notebook()
|
||||
async with connect(mcp) as client:
|
||||
with anyio.fail_after(10):
|
||||
async with client.listen( # pragma: no branch
|
||||
tools_list_changed=True, resource_subscriptions=["note://todo"]
|
||||
) as sub:
|
||||
assert sub.honored.tools_list_changed is True
|
||||
assert sub.honored.resource_subscriptions == ["note://todo"]
|
||||
|
||||
await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent
|
||||
await client.call_tool("edit_note", {"name": "todo"})
|
||||
assert await anext(sub) == ResourceUpdated(uri="note://todo")
|
||||
|
||||
await client.call_tool("touch_tools", {})
|
||||
assert await anext(sub) == ToolsListChanged()
|
||||
|
||||
|
||||
@requirement("subscriptions:listen:client:era-guard")
|
||||
async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None:
|
||||
"""On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs."""
|
||||
mcp = _notebook()
|
||||
async with connect(mcp) as client:
|
||||
with anyio.fail_after(10):
|
||||
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
|
||||
with pytest.raises(ListenNotSupportedError) as exc_info:
|
||||
await client.listen(tools_list_changed=True).__aenter__()
|
||||
assert exc_info.value.negotiated_version == client.session.protocol_version
|
||||
assert "subscribe_resource" in str(exc_info.value)
|
||||
Loading…
Reference in a new issue