Title: Flaky streamable-HTTP/SSE tests: TOCTOU port race under pytest -n auto · Issue #2704 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: Flaky streamable-HTTP/SSE tests: TOCTOU port race under pytest -n auto · Issue #2704 · modelcontextprotocol/python-sdk
X Title: Flaky streamable-HTTP/SSE tests: TOCTOU port race under pytest -n auto · Issue #2704 · modelcontextprotocol/python-sdk
Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue Description Summary Tests in tests...
Open Graph Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this ...
X Description: Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening t...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/issues/2704
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Flaky streamable-HTTP/SSE tests: TOCTOU port race under pytest -n auto","articleBody":"### Initial Checks\n\n- [x] I confirm that I'm using the latest version of MCP Python SDK\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### Summary\n\n Tests in `tests/shared/test_streamable_http.py` intermittently fail in CI (non-deterministically, across different Python versions). The failures are not real regressions, they're caused by a time-of-check/time-of-use (TOCTOU) race in how the test server fixtures allocate ports, which collides when tests run in parallel under pytest -n auto.\n\n### Evidence\n \n The flakiness is intermittent and non-deterministic: the same commit, re-run across the CI matrix, fails on different tests and different Python versions, while passing locally and on most matrix entries. That pattern, a failure that moves around rather than reproducing on a specific test/version, is the signature of a parallelism race, not a code defect.\n\n Two failure signatures have been observed, and both reduce to \"the client connected to the wrong server instance\":\n\n 1. Server can't bind the port (`test_streamable_http_client_session_termination_204`)\n\n ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 35105): address already in use\n AssertionError: assert 2 == 10\n The intended server (10 tools) loses the bind race, so the client reaches a different test's server: `len(tools.tools)` comes back as `2` (the `echo_headers/echo_context server`) instead of 10.\n\n 2. Crossed responses (`test_streamable_http_client_respects_retry_interval`)\n\n pydantic_core.ValidationError: 1 validation error for CallToolResult\n content\n Field required [type=missing, input_value={'tools': [...]}, input_type=dict]\n A call_tool request is answered with a `ListToolsResult` payload (`{'tools': [...]}`), which fails to validate as `CallToolResult`. The client is talking to a server/stream that belongs to another test.\n \n Both symptoms are downstream of two servers contending for the same ephemeral port — see Root cause below.\n\n\n### Root cause\n\nThe port fixtures pick a port, then close the socket before the real server binds it:\n\n```python\n# tests/shared/test_streamable_http.py:474\n@pytest.fixture\ndef basic_server_port() -\u003e int:\n with socket.socket() as s:\n s.bind((\"127.0.0.1\", 0)) # OS assigns a free port\n return s.getsockname()[1] # ...socket closes here, freeing the port\n```\n\nThe server is then started in a separate `multiprocessing.Process` (`run_server`, line 435) and binds that port later. In the gap, another xdist worker's fixture can be handed the same port by the OS, so two servers race for it - one fails with `Errno 98`, and clients can reach the wrong server.\n\n### Affected files (same pattern)\n\n - tests/shared/test_streamable_http.py\n - tests/shared/test_sse.py\n - tests/server/test_sse_security.py\n - tests/server/test_streamable_http_security.py\n - tests/client/test_http_unicode.py\n\n### Proposed fix\n\nReuse the existing race-free helper `run_uvicorn_in_thread` in `tests/test_helpers.py:15`. It binds and `listen()`s a socket, then hands that same socket to uvicorn (`server.run(sockets=[sock])`), so there is no window where another worker can claim the port. This pattern is already proven in tests/shared/test_ws.py, and its docstring documents exactly this race.\n\nMigrate the racy fixtures, e.g.:\n\n```python\n# before: basic_server_port + basic_server + basic_server_url (3 fixtures, racy)\n# after:\n@pytest.fixture\ndef basic_server_url() -\u003e Generator[str, None, None]:\n app = create_app()\n with run_uvicorn_in_thread(app, limit_concurrency=10, timeout_keep_alive=5, access_log=False) as url:\n yield url\n```\n\nThis also removes the need for the `wait_for_server(port)` poll (the helper's pre-listen()ed socket means connections are accepted as soon as the fixture yields).\n\n### Considerations\n\n - This converts the test servers from a subprocess to a background thread (as `test_ws.py` already does). Need to confirm no test relies on subprocess semantics (e.g. `proc.kill()`); the streamable-HTTP tests appear to test HTTP-level behavior, not process lifecycle.\n - A subprocess-preserving alternative (pass a pre-bound listening socket into the child via `server.run(sockets=[sock]))` is harder cross-platform, Windows spawn can't easily inherit/pickle sockets, so the thread helper is preferred for the CI matrix.\n\n### Scope\n\nPrimary scope: `tests/shared/test_streamable_http.py`. The 4 sibling files share the root cause and can be migrated in the same PR or as follow-ups.\n\n### Acceptance criteria\n\n - Racy `*_port` fixtures + `run_server/wait_for_server` subprocess pattern removed from the affected file(s).\n - Tests pass reliably under `uv run pytest -n auto` across the CI matrix (3.10–3.14, ubuntu + windows).\n\n\n### Example Code\n\n```Python\nimport socket\n\ndef pick_free_port() -\u003e int:\n # Exact pattern used by basic_server_port / json_server_port / event_server_port\n # in tests/shared/test_streamable_http.py\n with socket.socket() as s:\n s.bind((\"127.0.0.1\", 0))\n return s.getsockname()[1] # \u003c- socket closes here, so the port is free again\n\n\n# A fixture assigns this port to \"server A\"...\nport = pick_free_port()\n\n# ...but server A is started later (in a separate multiprocessing.Process), so the\n# port sits free in between. Under `pytest -n auto`, another worker can claim it.\n# Simulate that intruder grabbing the port during the window:\nintruder = socket.socket()\nintruder.bind((\"127.0.0.1\", port))\nintruder.listen()\n\n# Now server A finally tries to bind the port it was handed:\nserver_a = socket.socket()\nserver_a.bind((\"127.0.0.1\", port)) # OSError: [Errno 98] Address already in use\n```\n\n### Python \u0026 MCP Python SDK\n\n```Text\nPython: CPython 3.10 and 3.13 (failures captured on ubuntu-latest in CI).\n Not version-specific, it's a test-parallelism race, so it can surface anywhere on the 3.10–3.14 matrix.\n\nMCP Python SDK: main branch (the unreleased v2 line) — 1.25.1.dev builds.\n```","author":{"url":"https://github.com/Ar-maan05","@type":"Person","name":"Ar-maan05"},"datePublished":"2026-05-28T08:10:33.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":5},"url":"https://github.com/2704/python-sdk/issues/2704"}
| 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:e450c009-ea43-32e5-1f4d-b8af86f1f97f |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | AB52:38363:1A05840:252E21C:6A5A393C |
| html-safe-nonce | 38ea584daad35e212c89990d89bfbb88760a6e02821bdf55458130611f5a9db7 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJBQjUyOjM4MzYzOjFBMDU4NDA6MjUyRTIxQzo2QTVBMzkzQyIsInZpc2l0b3JfaWQiOiI4NjE2NTkyNzI5OTQxMDk2NzY0IiwicmVnaW9uX2VkZ2UiOiJpYWQiLCJyZWdpb25fcmVuZGVyIjoiaWFkIn0= |
| visitor-hmac | aa5520cadf8be42f497263f8e905cb15ff98a9c7c620230546cd5c298d9b08b2 |
| hovercard-subject-tag | issue:4538807209 |
| 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/2704/issue_layout |
| twitter:image | https://opengraph.githubassets.com/3388fe83a32faa4c46ceca4820a3418ae343c43217144d45908d3a1448a58ef3/modelcontextprotocol/python-sdk/issues/2704 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/3388fe83a32faa4c46ceca4820a3418ae343c43217144d45908d3a1448a58ef3/modelcontextprotocol/python-sdk/issues/2704 |
| og:image:alt | Initial Checks I confirm that I'm using the latest version of MCP Python SDK I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this ... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | Ar-maan05 |
| hostname | github.com |
| expected-hostname | github.com |
| None | 19c67ad7579d2401ff06e88a32ef45460fbb40fab3191c7f7582d5e166f326fc |
| 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 | e618485bff22dab2ef1ffb4e34e4e6626688df69 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width