Title: `ClientSession` never sends `notifications/cancelled` when `call_tool` is cancelled — server-side coroutines leak · Issue #2507 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: `ClientSession` never sends `notifications/cancelled` when `call_tool` is cancelled — server-side coroutines leak · Issue #2507 · modelcontextprotocol/python-sdk
X Title: `ClientSession` never sends `notifications/cancelled` when `call_tool` is cancelled — server-side coroutines leak · Issue #2507 · modelcontextprotocol/python-sdk
Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK (1.29.0 verified) I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue Description Clie...
Open Graph Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK (1.29.0 verified) I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues be...
X Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK (1.29.0 verified) I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issue...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/issues/2507
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"`ClientSession` never sends `notifications/cancelled` when `call_tool` is cancelled — server-side coroutines leak","articleBody":"### Initial Checks\n\n- [x] I confirm that I'm using the latest version of MCP Python SDK (1.29.0 verified)\n- [x] I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue\n\n### Description\n\n`ClientSession.send_request()` (and therefore every `call_tool`, `list_tools`, etc.) never emits a `notifications/cancelled` message when its in-flight `await` is interrupted, regardless of whether the interruption comes from the SDK's own timeout or from the caller's `asyncio.wait_for`. The MCP spec (cancellation.mdx) requires the sender to issue this notification on timeout, and any cooperative cancellation likewise leaves the server with an orphan request.\n\nEmpirical impact: server-side tool coroutines remain suspended after a client cancellation. They hold whatever resources they had acquired (DB connections, cursors, locks, file handles) until the session itself ends. With long-lived sessions and cancellable workloads, every cancelled call is a silent leak.\n\nThis was previously raised in #1458 (\"Missing Cancellation Notifications on Request Timeout\") and closed as `DUPLICATE`, but the proposed fix never landed and the underlying behavior is still present in `1.29.0`. The current report adds a second uncovered path (external `CancelledError`) and concrete evidence of the resource-leak impact, so I'm filing rather than commenting on the closed issue.\n\n### Two paths, neither sends the notification\n\n**Path A — SDK-internal timeout (`anyio.fail_after`):** Already documented in #1458. The `except TimeoutError` branch raises `McpError` and falls into the `finally` block that closes the local response stream. No `CancelledNotification` is sent.\n\n`mcp/shared/session.py:290-303` (1.29.0):\n\n```python\ntry:\n with anyio.fail_after(timeout):\n response_or_error = await response_stream_reader.receive()\nexcept TimeoutError:\n raise McpError(\n ErrorData(\n code=httpx.codes.REQUEST_TIMEOUT,\n message=(\n f\"Timed out while waiting for response to \"\n f\"{request.__class__.__name__}. Waited \"\n f\"{timeout} seconds.\"\n ),\n )\n )\n```\n\n**Path B — external cancellation:** When the caller wraps `session.call_tool(...)` in `asyncio.wait_for(...)` (or any other cancellation source), `asyncio.CancelledError` is raised at the `await response_stream_reader.receive()` point. There is no `except` for `CancelledError` / `anyio.get_cancelled_exc_class()` anywhere in `send_request`. The exception flows up through the `finally`, which only cleans up the *client-local* response stream:\n\n`mcp/shared/session.py:310-313`:\n\n```python\nfinally:\n self._response_streams.pop(request_id, None)\n self._progress_callbacks.pop(request_id, None)\n await response_stream.aclose()\n await response_stream_reader.aclose()\n```\n\nThe server is never told the request is gone. Its in-flight tool task continues to completion, then sends back a response that gets dropped because no one is reading the response stream.\n\n### Reproduction\n\nMinimal repro script (full version: https://github.com/sherman94062/databricks-ai-steward/blob/main/stress/probe_a1_leak.py):\n\n```python\nimport asyncio\nfrom mcp import ClientSession, StdioServerParameters\nfrom mcp.client.stdio import stdio_client\n\n# stress.server is a tiny FastMCP server with two tools:\n# hangs_forever_async_guarded — `await asyncio.sleep(300)`\n# task_count — returns len(asyncio.all_tasks()) - 1\nasync def main():\n params = StdioServerParameters(\n command=\"python\", args=[\"-m\", \"stress.server\"]\n )\n async with stdio_client(params) as (read, write):\n async with ClientSession(read, write) as session:\n await session.initialize()\n\n baseline = await session.call_tool(\"task_count\", {})\n print(\"baseline:\", baseline)\n\n for _ in range(50):\n try:\n await asyncio.wait_for(\n session.call_tool(\"hangs_forever_async_guarded\", {}),\n timeout=0.1,\n )\n except asyncio.TimeoutError:\n pass\n\n await asyncio.sleep(0.5) # let any cleanup settle\n after = await session.call_tool(\"task_count\", {})\n print(\"after 50 cancels:\", after)\n\nasyncio.run(main())\n```\n\nOutput (mcp 1.29.0, Python 3.14):\n\n```\nbaseline: 4 tasks\nafter 50 cancels: 54 tasks\n```\n\n50 cancelled `call_tool` invocations → 50 leaked server-side coroutines, persistent until the session closes. Same numbers under stdio and `streamable_http`.\n\nIf the client sent `notifications/cancelled`, the server's existing handler at `mcp/shared/session.py:402-406` would cancel each leaked coroutine immediately:\n\n```python\nif isinstance(notification.root, CancelledNotification):\n cancelled_id = notification.root.params.requestId\n if cancelled_id in self._in_flight:\n await self._in_flight[cancelled_id].cancel()\n```\n\nThe server side already does the right thing on receipt. Only the client-side emit is missing.\n\n### Spec citations\n\n\u003e Implementations **SHOULD** establish timeouts for all sent requests… When the request has not received a success or error response within the timeout period, the sender **SHOULD** issue a cancellation notification for that request and stop waiting for a response.\n\n\u003e Either side can cancel an in-progress request by sending a cancellation notification.\n\n— https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/utilities/cancellation.mdx\n\n### Proposed fix\n\nTwo small additions to `BaseSession.send_request`. The internal-timeout branch is exactly what #1458 proposed; the external-cancellation branch is new and uses `anyio.get_cancelled_exc_class()` so it works under both asyncio and trio backends:\n\n```python\ntry:\n with anyio.fail_after(timeout):\n response_or_error = await response_stream_reader.receive()\nexcept TimeoutError:\n await self._send_cancelled_notification(request_id, \"request timed out\")\n raise McpError(...)\nexcept anyio.get_cancelled_exc_class():\n await self._send_cancelled_notification(request_id, \"request cancelled by caller\")\n raise\n\n\nasync def _send_cancelled_notification(self, request_id, reason):\n try:\n await self.send_notification(\n ClientNotification(\n CancelledNotification(\n method=\"notifications/cancelled\",\n params=CancelledNotificationParams(\n requestId=request_id, reason=reason\n ),\n )\n )\n )\n except Exception:\n # Best-effort: if the transport is already gone, nothing to do.\n logger.warning(\n \"failed to send cancellation notification for request %s\",\n request_id,\n )\n```\n\nThe notification *must* be sent before re-raising — once the cancellation propagates out of `send_request`, the caller may close the session and the write stream becomes unusable. A small async-shielded wrapper around the `send_notification` call may be needed to guarantee delivery on the cancellation path; happy to put that into a PR.\n\n### Why this matters in practice\n\nMost production MCP servers acquire external resources inside tool handlers — DB connections, HTTP clients, transactions, file locks. Without the cancellation notification, every aborted client call wastes one such resource for the lifetime of the session. We discovered this while building a Databricks-facing MCP server: a connection pool of 10 plus 10 cancelled tool calls = pool exhausted.\n\nA server-side per-tool timeout (`asyncio.wait_for` inside the tool wrapper) bounds the leak window, but it shouldn't be load-bearing. The client should tell the server when a request is dead.\n\n### Environment\n\n- `mcp` 1.29.0 (latest at time of writing)\n- Python 3.14.3, macOS 14\n- Same behavior reproduced with `streamable_http` transport (different transport, identical client cancellation path)\n\n### Related\n\n- #1458 — same bug, closed as DUPLICATE, fix not shipped\n- #1420 — closed; missing cancellation reason logging (server-side)\n- #1419, #2480 — closed/open; server-side spec gap (sending response after cancellation)\n- #2416 — open; server-side cancellation race\n","author":{"url":"https://github.com/sherman94062","@type":"Person","name":"sherman94062"},"datePublished":"2026-04-26T15:08:53.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":2},"url":"https://github.com/2507/python-sdk/issues/2507"}
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:b797481f-45b9-e47a-84f8-6534e9d5e9e0 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | D76E:1F8A40:25F46F:3636E0:6A59D5E9 |
| html-safe-nonce | 965801f17c2c1e0e85fe08dcf957a9aa0c37a4460f9496018b0c869edc8fb564 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJENzZFOjFGOEE0MDoyNUY0NkY6MzYzNkUwOjZBNTlENUU5IiwidmlzaXRvcl9pZCI6IjQxMTkyOTczMTE0MDIyMTg5ODUiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | d996974aaaacdc52921ea5eabe07f650a7f882c9a5b0be6441d1b65f55252f73 |
| hovercard-subject-tag | issue:4331191649 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/modelcontextprotocol/python-sdk/2507/issue_layout |
| twitter:image | https://opengraph.githubassets.com/8013fe12e8b16a1d6caa401b60b524668d26412d93ec2a87b04a80b9dbe32421/modelcontextprotocol/python-sdk/issues/2507 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/8013fe12e8b16a1d6caa401b60b524668d26412d93ec2a87b04a80b9dbe32421/modelcontextprotocol/python-sdk/issues/2507 |
| og:image:alt | Initial Checks I confirm that I'm using the latest version of MCP Python SDK (1.29.0 verified) I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues be... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | sherman94062 |
| hostname | github.com |
| expected-hostname | github.com |
| None | ba3976babb66479b1c943a8edc0777d96157da48fadc0161f9ddb219deee8353 |
| turbo-cache-control | no-preview |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | bfacf98c3b7ad151665d6ddd216469389872b251 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width