|
42 | 42 | RequestId, |
43 | 43 | RequestParams, |
44 | 44 | SetLevelRequestParams, |
| 45 | + SubscriptionsListenRequestParams, |
| 46 | + SubscriptionsListenResult, |
45 | 47 | Tool, |
46 | 48 | UnsupportedProtocolVersionErrorData, |
47 | 49 | ) |
@@ -2141,6 +2143,139 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo |
2141 | 2143 | assert result.id == 10 |
2142 | 2144 |
|
2143 | 2145 |
|
| 2146 | +@pytest.mark.anyio |
| 2147 | +async def test_dual_era_loop_listen_with_request_id_zero_stamps_and_resolves_the_falsy_id(): |
| 2148 | + """Request id 0 is falsy but valid: the ack and the graceful-close result |
| 2149 | + are stamped with the literal 0, the pending request keeps its id through |
| 2150 | + the whole lifecycle, and the ack still locks the era.""" |
| 2151 | + bus = InMemorySubscriptionBus() |
| 2152 | + handler = ListenHandler(bus) |
| 2153 | + srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler) |
| 2154 | + on_notify, notes_send, notes = _note_collector() |
| 2155 | + result_box: list[dict[str, Any]] = [] |
| 2156 | + try: |
| 2157 | + async with dual_era_client(srv, client_on_notify=on_notify) as (client, _): |
| 2158 | + async with anyio.create_task_group() as tg: |
| 2159 | + |
| 2160 | + async def open_listen() -> None: |
| 2161 | + result_box.append( |
| 2162 | + await client.send_raw_request( |
| 2163 | + "subscriptions/listen", _modern_listen_params(), {"request_id": 0} |
| 2164 | + ) |
| 2165 | + ) |
| 2166 | + |
| 2167 | + tg.start_soon(open_listen) |
| 2168 | + method, params = await notes.receive() |
| 2169 | + assert method == ACK_METHOD |
| 2170 | + assert params["_meta"][SUBSCRIPTION_ID_META_KEY] == 0 |
| 2171 | + with pytest.raises(MCPError) as init_exc: |
| 2172 | + await client.send_raw_request("initialize", _initialize_params()) |
| 2173 | + assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION |
| 2174 | + handler.close() |
| 2175 | + finally: |
| 2176 | + notes_send.close() |
| 2177 | + notes.close() |
| 2178 | + assert result_box[0]["_meta"][SUBSCRIPTION_ID_META_KEY] == 0 |
| 2179 | + |
| 2180 | + |
| 2181 | +@pytest.mark.anyio |
| 2182 | +async def test_dual_era_loop_double_cancel_and_stale_cancel_are_silent_and_recycle_the_slot(): |
| 2183 | + """Cancel idempotence over the loop: a double cancel of a live listen is |
| 2184 | + total silence and frees its max_subscriptions=1 slot; a cancel for an |
| 2185 | + already-resolved listen id is ignored and the connection keeps serving.""" |
| 2186 | + bus = InMemorySubscriptionBus() |
| 2187 | + handler = ListenHandler(bus, max_subscriptions=1) |
| 2188 | + srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler) |
| 2189 | + async with raw_dual_era_loop(srv) as (c2s, s2c): |
| 2190 | + c2s.send_nowait(_raw_req("d1", "subscriptions/listen", _modern_listen_params())) |
| 2191 | + await _expect_ack(s2c, "d1") |
| 2192 | + c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d1"})) |
| 2193 | + c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d1"})) |
| 2194 | + await anyio.wait_all_tasks_blocked() |
| 2195 | + with pytest.raises(anyio.WouldBlock): |
| 2196 | + s2c.receive_nowait() |
| 2197 | + # The slot is free again: with max_subscriptions=1 a second listen |
| 2198 | + # could only ack if the cancelled stream fully cleaned up. |
| 2199 | + c2s.send_nowait(_raw_req("d2", "subscriptions/listen", _modern_listen_params())) |
| 2200 | + await _expect_ack(s2c, "d2") |
| 2201 | + handler.close() |
| 2202 | + result = (await s2c.receive()).message |
| 2203 | + assert isinstance(result, JSONRPCResponse) |
| 2204 | + assert result.id == "d2" |
| 2205 | + # A stale cancel for the resolved id is ignored: no frame for d2, and |
| 2206 | + # a third listen still serves. |
| 2207 | + c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d2"})) |
| 2208 | + c2s.send_nowait(_raw_req("d3", "subscriptions/listen", _modern_listen_params())) |
| 2209 | + await _expect_ack(s2c, "d3") |
| 2210 | + await anyio.wait_all_tasks_blocked() |
| 2211 | + with pytest.raises(anyio.WouldBlock): |
| 2212 | + s2c.receive_nowait() |
| 2213 | + |
| 2214 | + |
| 2215 | +@pytest.mark.anyio |
| 2216 | +async def test_dual_era_loop_cancelling_one_of_two_listens_keeps_the_other_flowing(): |
| 2217 | + """Two live listen streams on one connection: cancelling one is silent for |
| 2218 | + its id and events keep fanning out to the survivor, stamped with the |
| 2219 | + survivor's subscription id only.""" |
| 2220 | + bus = InMemorySubscriptionBus() |
| 2221 | + handler = ListenHandler(bus) |
| 2222 | + srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler) |
| 2223 | + async with raw_dual_era_loop(srv) as (c2s, s2c): |
| 2224 | + c2s.send_nowait(_raw_req("a", "subscriptions/listen", _modern_listen_params())) |
| 2225 | + c2s.send_nowait(_raw_req("b", "subscriptions/listen", _modern_listen_params())) |
| 2226 | + for expected in ("a", "b"): |
| 2227 | + await _expect_ack(s2c, expected) |
| 2228 | + c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "a"})) |
| 2229 | + await anyio.wait_all_tasks_blocked() |
| 2230 | + await bus.publish(ToolsListChanged()) |
| 2231 | + event = (await s2c.receive()).message |
| 2232 | + assert isinstance(event, JSONRPCNotification) |
| 2233 | + assert event.method == "notifications/tools/list_changed" |
| 2234 | + assert event.params is not None |
| 2235 | + assert event.params["_meta"][SUBSCRIPTION_ID_META_KEY] == "b" |
| 2236 | + # One event, one survivor: nothing was written for the cancelled "a". |
| 2237 | + await anyio.wait_all_tasks_blocked() |
| 2238 | + with pytest.raises(anyio.WouldBlock): |
| 2239 | + s2c.receive_nowait() |
| 2240 | + handler.close() |
| 2241 | + result = (await s2c.receive()).message |
| 2242 | + assert isinstance(result, JSONRPCResponse) |
| 2243 | + assert result.id == "b" |
| 2244 | + |
| 2245 | + |
| 2246 | +@pytest.mark.anyio |
| 2247 | +async def test_dual_era_loop_custom_listen_handler_serves_and_only_a_valid_result_locks(): |
| 2248 | + """A plain async function as `on_subscriptions_listen` (not |
| 2249 | + `ListenHandler`) serves over the loop like any request. A result missing |
| 2250 | + the required stamped `_meta` fails outbound validation with |
| 2251 | + INTERNAL_ERROR and - like every failed request - never locks the era; a |
| 2252 | + schema-valid result serves and locks.""" |
| 2253 | + |
| 2254 | + async def bad(ctx: Ctx, params: SubscriptionsListenRequestParams) -> SubscriptionsListenResult: |
| 2255 | + return SubscriptionsListenResult() # no _meta: schema-invalid |
| 2256 | + |
| 2257 | + srv_bad: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=bad) |
| 2258 | + async with dual_era_client(srv_bad) as (client, _): |
| 2259 | + with pytest.raises(MCPError) as exc_info: |
| 2260 | + await client.send_raw_request("subscriptions/listen", _modern_listen_params()) |
| 2261 | + assert exc_info.value.error.code == INTERNAL_ERROR |
| 2262 | + assert exc_info.value.error.message == "Handler returned an invalid result" |
| 2263 | + # The failed listen never locked: the legacy handshake still works. |
| 2264 | + init = await client.send_raw_request("initialize", _initialize_params()) |
| 2265 | + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION |
| 2266 | + |
| 2267 | + async def good(ctx: Ctx, params: SubscriptionsListenRequestParams) -> SubscriptionsListenResult: |
| 2268 | + return SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) |
| 2269 | + |
| 2270 | + srv_good: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=good) |
| 2271 | + async with dual_era_client(srv_good) as (client, _): |
| 2272 | + result = await client.send_raw_request("subscriptions/listen", _modern_listen_params()) |
| 2273 | + assert result["resultType"] == "complete" |
| 2274 | + with pytest.raises(MCPError) as init_exc: |
| 2275 | + await client.send_raw_request("initialize", _initialize_params()) |
| 2276 | + assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION |
| 2277 | + |
| 2278 | + |
2144 | 2279 | @pytest.mark.anyio |
2145 | 2280 | async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_http_entry(): |
2146 | 2281 | """An unmapped handler exception on a modern request surfaces as the |
|
0 commit comments