Skip to content

[Server] Bind Streamable HTTP sessions to the principal that opened them - #627

Open
nicolas-grekas wants to merge 2 commits into
modelcontextprotocol:mainfrom
nicolas-grekas:session-principal
Open

nicolas-grekas wants to merge 2 commits into
modelcontextprotocol:mainfrom
nicolas-grekas:session-principal

Conversation

@nicolas-grekas

Copy link
Copy Markdown

Fixes #529

A handshake-era session now only serves the principal that opened it, as the security best practices recommend. When another principal sends the session id, the server answers 404 like for an unknown session, and its DELETE doesn't end the session.

I found this while building an MCP server for the Symfony showcase app: carol could call tools on the session alice opened.

On the open questions of #529:

  1. The binding always applies. Clients keep using their own token, and a 404 makes them re-initialize, so I see nothing to opt in to.
  2. It's in Protocol, next to the other session checks, so custom stores don't need to know about it.
  3. A token without sub identifies no principal. An app that authenticates requests itself sets the new StreamableHttpTransport::PRINCIPAL_ATTRIBUTE request attribute.

AuthorizationMiddleware builds the principal from iss, sub and client_id/azp, like #625 does for requestState. Protocol only gets an opaque string and stores its SHA-256 in the session.

McpBundle (symfony/ai) authenticates with Symfony Security, not with AuthorizationMiddleware. It'll need to set this attribute from the user identifier for the binding to work there.

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component breaking change Breaking the Backwards Compatibility Promise auth Issues and PRs related to Authentication / OAuth bug Something isn't working labels Oct 27, 2026
@chr-hertel
chr-hertel requested a balanced review from Copilot October 27, 2026 23:41

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

A session-store read failure can bypass ownership checks, allowing a caller with a valid subjectless token to delete another principal’s session.

3 open issues
What changed in this PR

This PR adds principal binding to handshake-era Streamable HTTP sessions, addressing #529 within Protocol without requiring custom session stores to understand authentication.

Changes:

  • Derives a stable principal from token claims and passes it through transport callbacks.
  • Stores a principal fingerprint and checks ownership before session use or termination.
  • Adds binding tests and documents external authentication integration and compatibility changes.
File Description
tests/​Unit/​Server/​Transport/​SessionBindingTest.php Adds session ownership, token refresh, and deletion tests.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​AuthorizationMiddlewareTest.php Tests principal derivation from token claims.
tests/​Unit/​Server/​Transport/​AccessTokenFlowTest.php Adapts token-flow tests to principal-bound sessions.
tests/​Unit/​Server/​BuilderTest.php Updates expected session reads during termination.
src/​Server/​Transport/​TransportInterface.php Documents principal arguments for callbacks.
src/​Server/​Transport/​StreamableHttpTransport.php Reads the principal attribute for POST and DELETE requests.
src/​Server/​Transport/​ManagesTransportCallbacks.php Updates callback type annotations.
src/​Server/​Transport/​Http/​Middleware/​AuthorizationMiddleware.php Derives and attaches the authenticated principal.
src/​Server/​Transport/​BaseTransport.php Forwards principals to transport listeners.
src/​Server/​Protocol.php Stores fingerprints and checks session ownership.
docs/​run/​authorization.md Explains binding and application-provided principals.
CHANGELOG.md Records binding behavior and callback signature changes.

Review effort: Balanced

Comment thread src/Server/Protocol.php Outdated
Comment on lines +1205 to +1207
$fingerprint = $session->get(self::SESSION_PRINCIPAL);

return (\is_string($fingerprint) ? $fingerprint : null) === self::fingerprint($principal);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3c8363a: a session opened without a principal now records that, and a session that records no principal at all serves no one. So after a failed read, both the request and the DELETE are refused. Sessions opened before the upgrade get a 404 too, the CHANGELOG says so.

Comment thread src/Server/Protocol.php
return $this->sessionManager->createWithId($sessionId);
$session = $this->sessionManager->createWithId($sessionId);

if (!$this->servesPrincipal($session, $principal)) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3c8363a: who opened the session is checked before waiting for the lock, and again under it. Another principal gets the 404 of an unknown session and can't keep the lock busy. An initialize sent with the session id leaked the same way (503 instead of 400), and it now never takes the lock.

}

$this->handleSessionEnd($this->sessionId);
$this->handleSessionEnd($this->sessionId, $principal);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A DELETE for an unknown session also gets a 200, since handleDeleteRequest() always answers 200. In 3c8363a, the test now checks that both answers are the same. Answering 404 to both would also change unknown sessions, so I'd rather do that separately.

A session opened without a principal now records it, so a session the
store fails to read serves no one. Who opened a session is checked before
waiting for its lock, so another principal can't tell the session is busy,
and an initialize never takes the lock.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Issues and PRs related to Authentication / OAuth breaking change Breaking the Backwards Compatibility Promise bug Something isn't working Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server] Bind Streamable HTTP sessions to the authenticated principal

3 participants