Skip to content

Commit b9754c1

Browse files
committed
[Server] Answer 503 when the session owner cannot be read
A failed read no longer sends the owner to re-initialize, and a store outage no longer escapes from a DELETE.
1 parent bea407c commit b9754c1

6 files changed

Lines changed: 124 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
3030
* [BC Break] Answer `server/discover` with `DiscoverHandler`, which a handler added through `Builder::addRequestHandler()` can replace, and dispatch `RequestEvent`, `ResponseEvent` and `ErrorEvent` for it. A `StatelessProtocol` built without the `Builder` needs `DiscoverHandler` and a `MessageFactory` with `DiscoverRequest` to answer it.
3131
* Add a `binder` argument to `Builder::setRequestState()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; a token without a subject fails closed, and a `subject` resolver identifies users by another claim. `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes.
3232
* Rotate the `requestState` signing key without failing the calls in flight: `Builder::setRequestState()` and `RequestStateCodec` take a list of keys, the first signing and every key verifying. The wire format is unchanged, and a single key works as before.
33-
* [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. The principal is `AccessToken::getPrincipal()`: the token's issuer, client and subject. An application that identifies users another way sets the `StreamableHttpTransport::PRINCIPAL_ATTRIBUTE` request attribute to a non-empty string. `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.
33+
* [BC Break] Bind handshake-era sessions to the principal that opened them: `AccessToken::getPrincipal()`, or the `StreamableHttpTransport::PRINCIPAL_ATTRIBUTE` request attribute when set. Requests of another principal, and to sessions opened before the upgrade, are answered with `404`, and to a session the store cannot read with `503`. `Protocol::processInput()`, the transports' `handleMessage()`, `handleSessionEnd()`, `handlePostRequest()` and `handleDeleteRequest()`, and the `onMessage()` and `onSessionEnd()` listeners take a `?string $principal` as last argument.
3434

3535
### Client
3636

‎src/Server/Protocol.php‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class Protocol
7070

7171
public const SESSION_LOGGING_LEVEL = '_mcp.logging_level';
7272

73+
private const SESSION_NOT_FOUND_MESSAGE = 'Session not found or has expired.';
74+
7375
/** Session key for the fingerprint of the principal that opened the session, null when it was opened without one */
7476
private const SESSION_PRINCIPAL = '_mcp.principal';
7577

@@ -261,7 +263,15 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
261263
if ($takesLock) {
262264
// Who opened the session is checked before waiting for the lock too, so that another
263265
// principal can neither tell the session is busy nor keep it busy.
264-
if (!$this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal)) {
266+
try {
267+
$serves = $this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal);
268+
} catch (SessionStoreException $e) {
269+
$this->refuseUnavailableStore($transport, $sessionId, self::findResponseId($input), $e);
270+
271+
return;
272+
}
273+
274+
if (!$serves) {
265275
$this->refuseOtherPrincipal($transport, $sessionId);
266276

267277
return;
@@ -1168,27 +1178,29 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
11681178
try {
11691179
$exists = $this->sessionManager->exists($sessionId);
11701180
} catch (SessionStoreException $e) {
1171-
// Not a 404: the session may well exist, so the client should retry instead of re-initializing.
1172-
$this->logger->error('Session store is unavailable.', [
1173-
'session_id' => $sessionId->toRfc4122(),
1174-
'exception' => $e,
1175-
]);
1176-
$error = Error::forInternalError('Session store is unavailable.', self::findRequestId($messages));
1177-
$this->sendResponse($transport, $error, null, ['status_code' => 503]);
1181+
$this->refuseUnavailableStore($transport, $sessionId, self::findRequestId($messages), $e);
11781182

11791183
return null;
11801184
}
11811185

11821186
if (!$exists) {
1183-
$error = Error::forInvalidRequest('Session not found or has expired.');
1187+
$error = Error::forInvalidRequest(self::SESSION_NOT_FOUND_MESSAGE);
11841188
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
11851189

11861190
return null;
11871191
}
11881192

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

1191-
if (!$this->servesPrincipal($session, $principal)) {
1195+
try {
1196+
$serves = $this->servesPrincipal($session, $principal);
1197+
} catch (SessionStoreException $e) {
1198+
$this->refuseUnavailableStore($transport, $sessionId, self::findRequestId($messages), $e);
1199+
1200+
return null;
1201+
}
1202+
1203+
if (!$serves) {
11921204
$this->refuseOtherPrincipal($transport, $sessionId);
11931205

11941206
return null;
@@ -1200,11 +1212,35 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
12001212
/**
12011213
* Whether the session serves the principal: the one that opened it, or no principal for a session opened without one.
12021214
*
1203-
* A session that does not record who opened it, for example because the store failed to read it, serves no one.
1215+
* A session that does not record who opened it serves no one.
1216+
*
1217+
* @throws SessionStoreException when the session exists but its data cannot be read
12041218
*/
12051219
private function servesPrincipal(SessionInterface $session, ?string $principal): bool
12061220
{
1207-
return $session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal);
1221+
if ($session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal)) {
1222+
return true;
1223+
}
1224+
1225+
if ([] === $session->all()) {
1226+
throw new SessionStoreException(\sprintf('Session "%s" could not be read.', $session->getId()->toRfc4122()));
1227+
}
1228+
1229+
return false;
1230+
}
1231+
1232+
/**
1233+
* Not a 404: the session may well exist, so the client should retry instead of re-initializing.
1234+
*
1235+
* @param TransportInterface<mixed> $transport
1236+
*/
1237+
private function refuseUnavailableStore(TransportInterface $transport, Uuid $sessionId, string|int|null $requestId, SessionStoreException $exception): void
1238+
{
1239+
$this->logger->error('Session store is unavailable.', [
1240+
'session_id' => $sessionId->toRfc4122(),
1241+
'exception' => $exception,
1242+
]);
1243+
$this->sendResponse($transport, Error::forInternalError('Session store is unavailable.', $requestId), null, ['status_code' => 503]);
12081244
}
12091245

12101246
/**
@@ -1217,7 +1253,7 @@ private function refuseOtherPrincipal(TransportInterface $transport, Uuid $sessi
12171253
]);
12181254

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

@@ -1245,6 +1281,8 @@ private static function findRequestId(array $messages): string|int|null
12451281
* Ends a session at the request of a client, which must be the principal that opened it.
12461282
*
12471283
* @return bool false when the session does not exist, or is another principal's
1284+
*
1285+
* @throws SessionStoreException when who opened the session cannot be established; the session is not ended
12481286
*/
12491287
private function endSession(Uuid $sessionId, ?string $principal = null): bool
12501288
{

‎src/Server/Transport/BaseTransport.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Server\Transport;
1313

14+
use Mcp\Exception\SessionStoreException;
1415
use Mcp\Schema\JsonRpc\Error;
1516
use Mcp\Schema\JsonRpc\Response;
1617
use Mcp\Server\Authorization\AccessToken;
@@ -154,6 +155,8 @@ protected function handleMessage(string $payload, ?Uuid $sessionId, ?AccessToken
154155

155156
/**
156157
* @return bool false when the listener reported that the session does not exist
158+
*
159+
* @throws SessionStoreException when the listener cannot tell who opened the session
157160
*/
158161
protected function handleSessionEnd(?Uuid $sessionId, ?string $principal = null): bool
159162
{

‎src/Server/Transport/StreamableHttpTransport.php‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Http\Discovery\Psr17FactoryDiscovery;
1515
use Mcp\Exception\InvalidArgumentException;
16+
use Mcp\Exception\SessionStoreException;
1617
use Mcp\Schema\Enum\ProtocolVersion;
1718
use Mcp\Schema\JsonRpc\Error;
1819
use Mcp\Server\Authorization\AccessToken;
@@ -254,7 +255,15 @@ protected function handleDeleteRequest(?string $principal = null): ResponseInter
254255
return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header is required.'), 400);
255256
}
256257

257-
if (!$this->handleSessionEnd($this->sessionId, $principal)) {
258+
try {
259+
$ended = $this->handleSessionEnd($this->sessionId, $principal);
260+
} catch (SessionStoreException $e) {
261+
$this->logger->error('Session store is unavailable.', ['session_id' => $this->sessionId->toRfc4122(), 'exception' => $e]);
262+
263+
return $this->createErrorResponse(Error::forInternalError('Session store is unavailable.'), 503);
264+
}
265+
266+
if (!$ended) {
258267
return $this->createErrorResponse(Error::forInvalidRequest('Session not found or has expired.'), 404);
259268
}
260269

‎src/Server/Transport/TransportInterface.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ public function onMessage(callable $listener): void;
8484
*
8585
* The transport calls this when a client disconnects or explicitly ends their session.
8686
* The principal identifies who ended it, if the transport authenticates requests.
87+
* The listener throws a {@see \Mcp\Exception\SessionStoreException} when it cannot tell who opened the session.
8788
*
8889
* @param callable(Uuid $sessionId, ?string $principal): bool $listener The callback function to execute when destroying a session, returning false when the session does not exist
8990
*/

‎tests/Unit/Server/Transport/SessionBindingTest.php‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Tests\Unit\Server\Transport;
1313

14+
use Mcp\Exception\SessionStoreException;
1415
use Mcp\Schema\Enum\ProtocolVersion;
1516
use Mcp\Server;
1617
use Mcp\Server\Authorization\AccessToken;
@@ -22,6 +23,7 @@
2223
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
2324
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
2425
use Mcp\Server\Transport\StreamableHttpTransport;
26+
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionLock;
2527
use Nyholm\Psr7\Factory\Psr17Factory;
2628
use PHPUnit\Framework\Attributes\DataProvider;
2729
use PHPUnit\Framework\TestCase;
@@ -106,19 +108,56 @@ public function testDeleteOfAnotherPrincipalKeepsTheSession(): void
106108
$this->assertSame(404, $this->call($session, 'alice.ide.1')->getStatusCode());
107109
}
108110

109-
public function testSessionThatCannotBeReadServesNoRequestWithoutPrincipal(): void
111+
public function testSessionThatCannotBeReadIsRetriedByEveryone(): void
110112
{
111113
$session = $this->initialize('alice.ide.1');
112114
$passthrough = [new PassthroughMiddleware()];
113115

114116
$this->store->unreadable = true;
115-
$this->assertSame(404, $this->call($session, null, $passthrough)->getStatusCode());
116-
$this->serve($this->request('DELETE', '', null, $session), $passthrough);
117+
$this->assertSame(503, $this->call($session, 'alice.ide.1')->getStatusCode());
118+
$this->assertSame(503, $this->call($session, null, $passthrough)->getStatusCode());
119+
$this->assertSame(503, $this->serve($this->request('DELETE', '', 'alice.ide.1', $session))->getStatusCode());
120+
$this->assertSame(503, $this->serve($this->request('DELETE', '', null, $session), $passthrough)->getStatusCode());
117121
$this->store->unreadable = false;
118122

119123
$this->assertSame('alice', $this->text($this->call($session, 'alice.ide.1')));
120124
}
121125

126+
public function testSessionThatCannotBeReadIsRetriedWithASessionLock(): void
127+
{
128+
$log = [];
129+
$server = Server::builder()
130+
->setServerInfo('test', '1.0.0')
131+
->setSession($this->store)
132+
->setSessionLock(new RecordingSessionLock($log))
133+
->build();
134+
$session = $this->server($server, $this->request('POST', $this->initializeRequest(), 'alice.ide.1'))->getHeaderLine('Mcp-Session-Id');
135+
$log = [];
136+
137+
$this->store->unreadable = true;
138+
$this->assertSame(503, $this->server($server, $this->request('POST', $this->toolCall(), 'alice.ide.1', $session))->getStatusCode());
139+
$this->assertSame([], $log);
140+
}
141+
142+
public function testDeleteDuringAStoreOutageKeepsTheSession(): void
143+
{
144+
$session = $this->initialize('alice.ide.1');
145+
146+
$this->store->unavailable = true;
147+
$this->assertSame(503, $this->serve($this->request('DELETE', '', 'alice.ide.1', $session))->getStatusCode());
148+
$this->store->unavailable = false;
149+
150+
$this->assertSame('alice', $this->text($this->call($session, 'alice.ide.1')));
151+
}
152+
153+
public function testSessionFromBeforeTheUpgradeIsNotFound(): void
154+
{
155+
$session = Uuid::v4();
156+
$this->store->write($session, json_encode(['initialized' => true], \JSON_THROW_ON_ERROR));
157+
158+
$this->assertSame(404, $this->call($session->toRfc4122(), 'alice.ide.1')->getStatusCode());
159+
}
160+
122161
public function testPrincipalSetByTheApplicationBindsTheSession(): void
123162
{
124163
$middleware = new class implements MiddlewareInterface {
@@ -240,6 +279,11 @@ private function serveWith(ServerRequestInterface $request, array $attributes):
240279
return $this->serve($request, [new PassthroughMiddleware()]);
241280
}
242281

282+
private function server(Server $server, ServerRequestInterface $request): ResponseInterface
283+
{
284+
return $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory, middleware: [$this->authorization()]));
285+
}
286+
243287
private function initialize(string $token): string
244288
{
245289
$session = $this->serve($this->request('POST', $this->initializeRequest(), $token))->getHeaderLine('Mcp-Session-Id');
@@ -341,10 +385,21 @@ private function text(ResponseInterface $response): string
341385

342386
/**
343387
* Finds sessions but fails to read them while $unreadable is set, like a cache that is reachable but fails on get().
388+
* Fails to look them up while $unavailable is set, like a cache that is down.
344389
*/
345390
final class UnreadableSessionStore extends InMemorySessionStore
346391
{
347392
public bool $unreadable = false;
393+
public bool $unavailable = false;
394+
395+
public function exists(Uuid $id): bool
396+
{
397+
if ($this->unavailable) {
398+
throw new SessionStoreException('Session store is unavailable.');
399+
}
400+
401+
return parent::exists($id);
402+
}
348403

349404
public function read(Uuid $id): string|false
350405
{

0 commit comments

Comments
 (0)