Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.10.0
------

* [BC Break] Bind handshake-era sessions to the principal that opened them, as the security best practices recommend. A request of another principal with the session's id is answered with `404`, like an unknown session, and its `DELETE` does not end the session. Requests to sessions opened before the upgrade are answered with `404` too, so clients open new ones. `AuthorizationMiddleware` identifies the principal by the token's issuer, subject and client; an application that authenticates requests itself sets the `StreamableHttpTransport::PRINCIPAL_ATTRIBUTE` request attribute. `Protocol::processInput()`, `BaseTransport::handleMessage()`, `BaseTransport::handleSessionEnd()`, `StreamableHttpTransport::handlePostRequest()` and `StreamableHttpTransport::handleDeleteRequest()` take a `?string $principal` parameter, as do the `TransportInterface::onMessage()` and `onSessionEnd()` listeners as last argument.

0.9.0
-----

Expand Down
19 changes: 17 additions & 2 deletions docs/run/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,16 +313,31 @@ challenge a client steps up from. Use `$token->hasScope()` for decisions that de
arguments beyond the tool name. With a `ScopePolicy`, the token's scopes include those implied by
its hierarchy.

## Sessions and the Principal

A handshake-era session only serves the principal that opened it, as the
[security best practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#session-hijacking)
recommend. `AuthorizationMiddleware` identifies the principal by the issuer, subject and client of
the token. They stay the same when the client refreshes its token, so the session survives the refresh.

When another principal sends the session's `Mcp-Session-Id`, the server answers 404, as for an
unknown session, and a `DELETE` does not end the session. A token without a `sub` claim identifies
no principal. A session opened with such a token only serves requests without a principal.

## Authenticating Outside the SDK

If your application or framework already authenticates the request, skip `AuthorizationMiddleware`
and hand the result to the transport as the PSR-7 request attribute `AccessToken::class`. Both HTTP
transports read it from there, so handlers get it through `RequestContext::getAccessToken()` as usual:
transports read it from there, so handlers get it through `RequestContext::getAccessToken()` as usual.
Also set the `StreamableHttpTransport::PRINCIPAL_ATTRIBUTE` attribute to a string that identifies
the user, so that a session only serves the user that opened it:

```php
use Mcp\Server\Authorization\AccessToken;

$request = $request->withAttribute(AccessToken::class, new AccessToken($scopes, $claims));
$request = $request
->withAttribute(AccessToken::class, new AccessToken($scopes, $claims))
->withAttribute(StreamableHttpTransport::PRINCIPAL_ATTRIBUTE, $userId);
$transport = new StreamableHttpTransport($request);
```

Expand Down
96 changes: 82 additions & 14 deletions src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ class Protocol

public const SESSION_LOGGING_LEVEL = '_mcp.logging_level';

/** Session key for the fingerprint of the principal that opened the session, null when it was opened without one */
private const SESSION_PRINCIPAL = '_mcp.principal';

/**
* Deliberately generic: unexpected throwables carry internal details such as file paths, class
* names and argument types, which must not be handed to the peer. The full exception is logged.
Expand Down Expand Up @@ -139,7 +142,7 @@ public function connect(TransportInterface $transport): void
{
$transport->onMessage($this->processInput(...));

$transport->onSessionEnd($this->destroySession(...));
$transport->onSessionEnd($this->endSession(...));

// The transport keeps these callbacks, so they reference it weakly to not keep it alive.
$transportRef = \WeakReference::create($transport);
Expand Down Expand Up @@ -167,13 +170,14 @@ public function connect(TransportInterface $transport): void
*
* @param TransportInterface<mixed> $transport
* @param AccessToken|null $accessToken the token the input was authorized with, if the transport authorizes
* @param string|null $principal who the input was authenticated as, if the transport authenticates
*/
public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId, ?AccessToken $accessToken = null): void
public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId, ?AccessToken $accessToken = null, ?string $principal = null): void
{
// Last line of defense: a malformed message must never escape as a PHP error and take the
// server process down.
try {
$this->doProcessInput($transport, $input, $sessionId, $accessToken);
$this->doProcessInput($transport, $input, $sessionId, $accessToken, $principal);
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]);

Expand Down Expand Up @@ -230,7 +234,7 @@ private static function findResponseId(string $input): string|int|null
/**
* @param TransportInterface<mixed> $transport
*/
private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId, ?AccessToken $accessToken): void
private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId, ?AccessToken $accessToken, ?string $principal): void
{
$this->logger->info('Received message to process.');
$this->logger->debug('Received message payload.', ['message' => $input]);
Expand All @@ -248,13 +252,21 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
}

// Taken before the session is read and held until it is saved, so no other request of
// the session reads a state this one is about to replace. A session created here for
// an `initialize` is not known to anyone else yet and needs no lock, and an unknown
// session id is refused by resolveSession() without one; the session is looked up
// again under the lock, as it may have ended while this request waited.
$takesLock = null !== $sessionId && null !== $this->sessionLock && $this->sessionManager->exists($sessionId);
// the session reads a state this one is about to replace. An `initialize` never reads an
// existing session and an unknown session id is refused by resolveSession(), so neither
// needs the lock. The session is looked up again under the lock, as it may have ended
// while this request waited.
$takesLock = null !== $sessionId && null !== $this->sessionLock && !$this->hasInitializeRequest($messages) && $this->sessionManager->exists($sessionId);

if ($takesLock) {
// Who opened the session is checked before waiting for the lock too, so that another
// principal can neither tell the session is busy nor keep it busy.
if (!$this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal)) {
$this->refuseOtherPrincipal($transport, $sessionId);

return;
}

try {
$refusal = $this->acquireSessionLock($sessionId) ? null : Error::forServerError(self::SESSION_BUSY_MESSAGE, self::findResponseId($input));
} catch (SessionLockException) {
Expand All @@ -269,7 +281,7 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
}

try {
$this->handleMessages($transport, $sessionId, $messages, $accessToken);
$this->handleMessages($transport, $sessionId, $messages, $accessToken, $principal);
} finally {
if ($takesLock) {
$this->releaseSessionLock($sessionId);
Expand All @@ -281,9 +293,9 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
* @param TransportInterface<mixed> $transport
* @param array<int, mixed> $messages
*/
private function handleMessages(TransportInterface $transport, ?Uuid $sessionId, array $messages, ?AccessToken $accessToken): void
private function handleMessages(TransportInterface $transport, ?Uuid $sessionId, array $messages, ?AccessToken $accessToken, ?string $principal): void
{
$session = $this->resolveSession($transport, $sessionId, $messages);
$session = $this->resolveSession($transport, $sessionId, $messages, $principal);
if (null === $session) {
return;
}
Expand Down Expand Up @@ -1112,8 +1124,9 @@ private function hasInitializeRequest(array $messages): bool
* @param TransportInterface<mixed> $transport
* @param Uuid|null $sessionId The session ID from the transport
* @param array<int,mixed> $messages The parsed messages
* @param string|null $principal Who the messages were authenticated as
*/
private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, array $messages): ?SessionInterface
private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, array $messages, ?string $principal): ?SessionInterface
{
if ($this->hasInitializeRequest($messages)) {
// Spec: An initialize request must not be part of a batch.
Expand All @@ -1133,6 +1146,8 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
}

$session = $this->sessionManager->create();
$session->set(self::SESSION_PRINCIPAL, self::fingerprint($principal));

$this->logger->debug('Created new session for initialize', [
'session_id' => $session->getId()->toRfc4122(),
]);
Expand Down Expand Up @@ -1171,7 +1186,44 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
return null;
}

return $this->sessionManager->createWithId($sessionId);
$session = $this->sessionManager->createWithId($sessionId);

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

Copy link
Copy Markdown
Contributor 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->refuseOtherPrincipal($transport, $sessionId);

return null;
}

return $session;
}

/**
* Whether the session serves the principal: the one that opened it, or no principal for a session opened without one.
*
* A session that does not record who opened it, for example because the store failed to read it, serves no one.
*/
private function servesPrincipal(SessionInterface $session, ?string $principal): bool
{
return $session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal);
}

/**
* @param TransportInterface<mixed> $transport
*/
private function refuseOtherPrincipal(TransportInterface $transport, Uuid $sessionId): void
{
$this->logger->warning('Refused a request of another principal than the one that opened the session.', [
'session_id' => $sessionId->toRfc4122(),
]);

// Answered like an unknown session, so that another principal cannot tell the session exists.
$error = Error::forInvalidRequest('Session not found or has expired.');
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
}

private static function fingerprint(?string $principal): ?string
{
return null === $principal ? null : hash('sha256', $principal);
}

/**
Expand All @@ -1189,6 +1241,22 @@ private static function findRequestId(array $messages): string|int|null
};
}

/**
* Ends a session at the request of a client, which must be the principal that opened it.
*/
private function endSession(Uuid $sessionId, ?string $principal = null): void
{
if ($this->sessionManager->exists($sessionId) && !$this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal)) {
$this->logger->warning('Refused to end a session for another principal than the one that opened it.', [
'session_id' => $sessionId->toRfc4122(),
]);

return;
}

$this->destroySession($sessionId);
}

/**
* Destroy a specific session.
*/
Expand Down
8 changes: 4 additions & 4 deletions src/Server/Transport/BaseTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,17 @@ protected function handleFiberYield(mixed $yielded, ?Uuid $sessionId, \Fiber $fi
}
}

protected function handleMessage(string $payload, ?Uuid $sessionId, ?AccessToken $accessToken = null): void
protected function handleMessage(string $payload, ?Uuid $sessionId, ?AccessToken $accessToken = null, ?string $principal = null): void
{
if (\is_callable($this->messageListener)) {
($this->messageListener)($this, $payload, $sessionId, $accessToken);
($this->messageListener)($this, $payload, $sessionId, $accessToken, $principal);
}
}

protected function handleSessionEnd(?Uuid $sessionId): void
protected function handleSessionEnd(?Uuid $sessionId, ?string $principal = null): void
{
if ($sessionId && \is_callable($this->sessionEndListener)) {
($this->sessionEndListener)($sessionId);
($this->sessionEndListener)($sessionId, $principal);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
* - Returns 403 insufficient_scope when the token lacks a scope the {@see ScopePolicy} requires
* - Hands the validated {@see AccessToken} to the transport as request attribute,
* from where handlers read it via {@see \Mcp\Server\RequestContext::getAccessToken()}
* - Identifies the principal by the token's issuer, subject and client, so that a session
* only serves the principal that opened it, see {@see StreamableHttpTransport::PRINCIPAL_ATTRIBUTE}
*
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
*
Expand Down Expand Up @@ -113,7 +115,15 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$token = new AccessToken($granted, $token->getClaims());
}

return $handler->handle($request->withAttribute(AccessToken::class, $token));
$request = $request->withAttribute(AccessToken::class, $token);

if (null !== $subject = $token->getSubject()) {
// Kept across token refreshes, unlike the token itself or its scopes.
$principal = json_encode([$token->getClaim('iss'), $subject, $token->getClientId()], \JSON_THROW_ON_ERROR);
$request = $request->withAttribute(StreamableHttpTransport::PRINCIPAL_ATTRIBUTE, $principal);
}

return $handler->handle($request);
}

private function buildErrorResponse(AuthorizationResult $result): ResponseInterface
Expand Down
4 changes: 2 additions & 2 deletions src/Server/Transport/ManagesTransportCallbacks.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
* */
trait ManagesTransportCallbacks
{
/** @var callable(TransportInterface<mixed>, string, ?Uuid, ?AccessToken): void */
/** @var callable(TransportInterface<mixed>, string, ?Uuid, ?AccessToken, ?string): void */
protected $messageListener;

/** @var callable(Uuid): void */
/** @var callable(Uuid, ?string): void */
protected $sessionEndListener;

/** @var callable(Uuid): array<int, array{message: string, context: array<string, mixed>}> */
Expand Down
27 changes: 21 additions & 6 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra
public const SESSION_HEADER = 'Mcp-Session-Id';
public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version';

/**
* PSR-7 request attribute holding a string that identifies the authenticated principal.
*
* A session only serves the principal that opened it. {@see Http\Middleware\AuthorizationMiddleware}
* sets it; an application authenticating requests itself sets it instead.
*/
public const PRINCIPAL_ATTRIBUTE = 'mcp.principal';

/**
* Upper bound on the request body read for a POST, guarding against memory
* exhaustion from an oversized (or unbounded chunked) payload.
Expand Down Expand Up @@ -207,10 +215,11 @@ protected function handleOptionsRequest(): ResponseInterface
/**
* @param string $body the request body, already read and bounded by {@see self::handleRequest()}
* @param AccessToken|null $accessToken the token the request was authorized with, see {@see self::accessToken()}
* @param string|null $principal the principal the request was authenticated as, see {@see self::PRINCIPAL_ATTRIBUTE}
*/
protected function handlePostRequest(string $body, ?AccessToken $accessToken = null): ResponseInterface
protected function handlePostRequest(string $body, ?AccessToken $accessToken = null, ?string $principal = null): ResponseInterface
{
$this->handleMessage($body, $this->sessionId, $accessToken);
$this->handleMessage($body, $this->sessionId, $accessToken, $principal);

// Consume the immediate response exactly once, so a transport instance
// reused for a later POST does not replay it.
Expand All @@ -236,13 +245,16 @@ protected function handlePostRequest(string $body, ?AccessToken $accessToken = n
return $this->createJsonResponse();
}

protected function handleDeleteRequest(): ResponseInterface
/**
* @param string|null $principal the principal the request was authenticated as, see {@see self::PRINCIPAL_ATTRIBUTE}
*/
protected function handleDeleteRequest(?string $principal = null): ResponseInterface
{
if (!$this->sessionId) {
return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header is required.'), 400);
}

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

Copy link
Copy Markdown
Contributor 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.


return $this->responseFactory->createResponse(200);
}
Expand Down Expand Up @@ -512,9 +524,12 @@ private function handleHandshakeRequest(ServerRequestInterface $request, ?string
return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must be a valid UUID.'), 400);
}

$principal = $request->getAttribute(self::PRINCIPAL_ATTRIBUTE);
$principal = \is_string($principal) && '' !== $principal ? $principal : null;

return match ($request->getMethod()) {
'POST' => $this->handlePostRequest($body ?? '', self::accessToken($request)),
'DELETE' => $this->handleDeleteRequest(),
'POST' => $this->handlePostRequest($body ?? '', self::accessToken($request), $principal),
'DELETE' => $this->handleDeleteRequest($principal),
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
};
}
Expand Down
6 changes: 4 additions & 2 deletions src/Server/Transport/TransportInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,19 @@ public function close(): void;
* The transport calls this whenever ANY message arrives, regardless of source.
*
* The access token is the one the message was authorized with, if the transport authorizes requests.
* The principal identifies who the message was authenticated as, if the transport authenticates requests.
*
* @param callable(TransportInterface<TResult> $transport, string $message, ?Uuid $sessionId, ?AccessToken $accessToken): void $listener
* @param callable(TransportInterface<TResult> $transport, string $message, ?Uuid $sessionId, ?AccessToken $accessToken, ?string $principal): void $listener
*/
public function onMessage(callable $listener): void;

/**
* Register a listener for when a session is terminated.
*
* The transport calls this when a client disconnects or explicitly ends their session.
* The principal identifies who ended it, if the transport authenticates requests.
*
* @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session
* @param callable(Uuid $sessionId, ?string $principal): void $listener The callback function to execute when destroying a session
*/
public function onSessionEnd(callable $listener): void;

Expand Down
Loading