Skip to content

[Server] Poll only the client request a stream's own fiber sent - #556

Merged
chr-hertel merged 3 commits into
modelcontextprotocol:mainfrom
mglaman:fix/544-pending-request-ownership
Oct 9, 2026
Merged

chr-hertel merged 3 commits into
modelcontextprotocol:mainfrom
mglaman:fix/544-pending-request-ownership

Conversation

@mglaman

@mglaman mglaman commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Closes #544

Pending server-to-client requests live in one session-wide list, _mcp.pending_requests. The SSE loop in StreamableHttpTransport::createStreamedResponse() (and the stdio loop) walked that whole list. With two tool calls waiting in ClientGateway::elicit() on one session, the first stream to poll could take the other's answer and resume its own fiber with it.

Protocol now records which client request each connected transport's fiber is suspended on, and the pending-requests provider it hands that transport returns only that one:

$transport->setPendingRequestsProvider(fn (Uuid $sessionId): array => $this->getAwaitedPendingRequests($transport, $sessionId));
  • handleRequest() records the request from the fiber's first suspend before attachFiberToSession().
  • handleFiberYield() returns the ID of the request it sent, so a request yielded during a later resume replaces the recorded one. A notification yield clears it.
  • The map is a WeakMap keyed by transport. A fiber waits on one client request at a time, so each transport holds one ID.

I kept the tracking in Protocol so neither TransportInterface nor BaseTransport's protected hooks change. Drupal's mcp_server overrides handleFiberYield() and checkForResponse() in a StreamableHttpTransport subclass to lock the session per write, and those overrides keep working.

Not in this PR

@jarrettdustinqq's review raises two lifecycle gaps that exist on main today:

  • A timed-out request stays in _mcp.pending_requests. After this change, other streams no longer see it, so it can't resume the wrong fiber.
  • A response arriving after its timeout is stored and never consumed.

Both are session cleanup rather than cross-stream delivery, and the whole-session read-modify-write race is #275. I'd rather handle them separately.

Tests

ProtocolTest starts two tool calls on one session, each suspended on a client request, through a PollingLoopTransport fixture that exposes what the transport's polling loop sees:

  • testConcurrentStreamsPollOnlyTheirOwnPendingRequest: after the client answers the second stream's request, each stream still sees only its own.
  • testRequestYieldedOnResumeIsPolledOnlyByItsOwnStream: a request yielded through the transport's fiber-yield handler is pending for that stream only.

Both fail on main (each stream sees [1000, 1001]).

Checked locally: php-cs-fixer is clean, phpstan reports no errors, and the unit (1,691) and integration (82) suites pass.

🤖 Generated with Claude Code

@chr-hertel chr-hertel added bug Something isn't working Server Issues & PRs related to the Server component P2 Moderate issues affecting some users, edge cases, potentially valuable feature labels Oct 8, 2026
Comment thread CHANGELOG.md Outdated
@chr-hertel

Copy link
Copy Markdown
Member

Thanks @mglaman - looks good at first glance, will have another look tomorrow 👍

@chr-hertel
chr-hertel requested a balanced review from Copilot October 8, 2026 00:05
@chr-hertel chr-hertel added this to the 0.9.0 milestone Oct 8, 2026

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.

🟢 Approval recommended

The focused change preserves transport extension hooks and includes regression coverage for initial and subsequent request yields.

0 open findings

What changed in this PR

Fixes cross-stream response delivery in the PHP SDK by limiting each transport’s polling to the client request its own fiber awaits.

Changes:

  • Tracks awaited request IDs per transport, including subsequent fiber yields.
  • Adds regression coverage for concurrent streams sharing a session.
  • Documents the fix and updated return value.
File Description
tests/​Unit/​Server/​ProtocolTest.php Tests request isolation across streams and subsequent yields.
tests/​Unit/​Fixtures/​PollingLoopTransport.php Exposes polling callbacks for regression tests.
src/​Server/​Protocol.php Tracks and filters pending requests per transport.
CHANGELOG.md Documents the polling fix and return-value change.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@mglaman

mglaman commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Applied the [BC Break] suggestion. The two unit (… lowest) failures are JwtTokenValidatorTest::testFromIssuerTagsKeysWithoutAlgorithmPerToken, which fails about 1 run in 150 because of an unpadded EC key coordinate. #558 fixes it. A rerun of the failed jobs should go green; I don't have rights to trigger one.

@mglaman
mglaman force-pushed the fix/544-pending-request-ownership branch from d724c0e to 4e58fff Compare October 8, 2026 18:19
Pending server-to-client requests live in one session-wide list. Each
SSE loop walked the whole list, so with two tool calls waiting on one
session, either stream could consume the other's elicitation answer
and resume its own fiber with it.

Protocol now records, per connected transport, the request its fiber
is suspended on, and the pending-requests provider returns only that
one.

🤖 Assisted with AI
@chr-hertel
chr-hertel force-pushed the fix/544-pending-request-ownership branch from 4e58fff to 19a24e1 Compare October 9, 2026 20:28
Cover a notification yield clearing the awaited request, and rename
awaitResponse() to trackAwaitedRequest().

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @mglaman!

@chr-hertel

Copy link
Copy Markdown
Member

Opened #561 as follow up for the timeout & late response cleanup 👍

@chr-hertel
chr-hertel merged commit 82ddda9 into modelcontextprotocol:main Oct 9, 2026
28 checks passed
@chr-hertel chr-hertel added the breaking change Breaking the Backwards Compatibility Promise label Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Breaking the Backwards Compatibility Promise bug Something isn't working P2 Moderate issues affecting some users, edge cases, potentially valuable feature Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server][Streamable HTTP] Concurrent SSE streams on one session can consume each other's client responses

3 participants