Skip to content

[Server] Expire timed out client requests in Protocol::checkResponse() - #585

Merged
chr-hertel merged 2 commits into
modelcontextprotocol:mainfrom
Arslan-TR:fix/561-timed-out-requests-cleanup
Oct 13, 2026
Merged

chr-hertel merged 2 commits into
modelcontextprotocol:mainfrom
Arslan-TR:fix/561-timed-out-requests-cleanup

Conversation

@Arslan-TR

Copy link
Copy Markdown
Contributor

Fixes #561

The unknown-ID half of the issue is already handled by handleResponse() on main; this covers the other half.

Problem

When a request to the client (elicitation, sampling) timed out, the polling loops of StreamableHttpTransport and StdioTransport resumed the fiber with a Request timed out error but never removed the entry from _mcp.pending_requests. Only Protocol::checkResponse() did that, and only for answers. So the entry stayed in the session until it ended, and a fiber that suspended again was resumed with the same stale timeout once more.

Change

  • Protocol::checkResponse() now returns the Request timed out error for a request past its timeout and drops its pending entry, like an answer does. An answer that is already stored still wins over the timeout.
  • The timeout branches (and their copies of the timestamp/timeout bookkeeping) are removed from both transports; checkForResponse() keeps its signature.
  • The expiry reloads the session before saving, so it does not write back a stale snapshot over what another stream stored in the meantime (ProtocolSessionRaceTest covers that case; its timed out request now returns the error instead of null). The general read-modify-write race stays with [Server][Streamable HTTP] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275.
  • StreamableHttpTransport loses its $clock constructor argument, as the transport no longer compares timestamps itself. It was added after v0.8.1, so it is not in a release yet. Protocol uses time(), like handleResponse() already does.
  • CHANGELOG entry added.

Tests

New in ProtocolTest: the timed out request is reported once and only its entry is dropped, a request within its timeout stays pending, a stored answer beats the timeout, a late answer after the reported timeout is dropped, and a stream stops polling a request that timed out. The StreamableHttpTransport test that relied on the injected clock now checks that the polling loop resumes the fiber with the error the response finder returns.

ProtocolTest::testCheckResponseReportsTimedOutRequestAndDropsItsPendingEntry, testAnswerAfterReportedTimeoutIsDropped, testStreamStopsPollingTimedOutRequest and the updated ProtocolSessionRaceTest case fail without the src/ change.

Ran locally: phpunit unit suite, phpstan (no errors), php-cs-fixer on the changed files. The unit suite still has failures that are the same without this change (FileSessionStoreTest permission tests, JwtTokenValidatorTest, HttpTransportListenTest, on Windows). The integration tests that spawn a stdio server did not finish here, and DualEraEndpointTest::testAsksForInput fails identically on main; I did not run the interop/conformance suites.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Expiration can still overwrite a response saved concurrently between the session reload and whole-session save.

1 open finding
What changed in this PR

Moves timed-out client-request cleanup into Protocol::checkResponse() and removes duplicate transport timeout handling.

Changes:

  • Expire pending requests centrally while preserving stored responses.
  • Simplify HTTP and stdio polling loops.
  • Add timeout, late-response, and concurrency tests.
File Description
src/​Server/​Protocol.php Implements centralized timeout expiration.
src/​Server/​Transport/​StreamableHttpTransport.php Removes transport timeout logic and clock injection.
src/​Server/​Transport/​StdioTransport.php Removes duplicate timeout handling.
tests/​Unit/​Server/​ProtocolTest.php Tests timeout and response behavior.
tests/​Unit/​Server/​ProtocolSessionRaceTest.php Updates concurrent-session coverage.
tests/​Unit/​Server/​Transport/​StreamableHttpTransportTest.php Tests forwarding timeout errors to fibers.
CHANGELOG.md Documents the fix.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Server/Protocol.php Outdated
@chr-hertel chr-hertel added the Server Issues & PRs related to the Server component label Oct 12, 2026
@chr-hertel chr-hertel added this to the 0.9.0 milestone Oct 12, 2026
The timeout branches in `StreamableHttpTransport` and `StdioTransport`
resumed the suspended fiber with an error but left the request in the
session's pending requests. A fiber that suspended again was then
resumed with that stale timeout once more, and the entry stayed in the
session until it ended.

`checkResponse()` now answers a request past its timeout with the
`Request timed out` error and drops its pending entry, like an answer
does, and the duplicated timeout logic is gone from both transports.
Expiry reloads the session before saving it back, so it does not undo
what other streams stored since the poll started.

`StreamableHttpTransport` loses its `$clock` constructor argument, which
only served the removed check.

Fixes modelcontextprotocol#561
@chr-hertel
chr-hertel force-pushed the fix/561-timed-out-requests-cleanup branch from ea41404 to 674a865 Compare October 13, 2026 21:43
@chr-hertel
chr-hertel requested a balanced review from Copilot October 13, 2026 21:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Timeout cleanup can overwrite concurrently added pending requests and orphan their suspended fibers.

1 open finding

🧠 Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Expiry can delete a newer concurrent request that reused the same non-atomically allocated ID.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Server/Protocol.php
@chr-hertel
chr-hertel merged commit 37ae0bb into modelcontextprotocol:main Oct 13, 2026
28 checks passed
chr-hertel added a commit that referenced this pull request Oct 14, 2026
…s answers (#581)

* [Server] Fix parallel elicitations on one session getting each other's answers

* [Server] Address review: cap client request ids at PHP_INT_MAX on 32-bit PHP

* [Server] Read the random client request ids in the tests of #580 and #585

* [Server] Address review: send requests to the client with UUID ids

* [Server] Address review: pass client request ids as Uuid instances

* [Server] Address review: answer the second stream's request by its UUID in the test

* [Server] Address review: don't send a client request the session could not store on a fiber yield

* [Server] Read the client request ids in the tests of #584
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server] Clean up timed-out client requests and drop responses nobody waits for

3 participants