Skip to content

Commit 3c8363a

Browse files
[Server] Fix the session binding on unreadable and busy sessions
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.
1 parent 2a24b6f commit 3c8363a

7 files changed

Lines changed: 147 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.10.0
66
------
77

8-
* [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. `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.
8+
* [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.
99

1010
0.9.0
1111
-----

‎src/Server/Protocol.php‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class Protocol
7070

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

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

7676
/**
@@ -252,13 +252,21 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
252252
}
253253

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

261261
if ($takesLock) {
262+
// Who opened the session is checked before waiting for the lock too, so that another
263+
// principal can neither tell the session is busy nor keep it busy.
264+
if (!$this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal)) {
265+
$this->refuseOtherPrincipal($transport, $sessionId);
266+
267+
return;
268+
}
269+
262270
try {
263271
$refusal = $this->acquireSessionLock($sessionId) ? null : Error::forServerError(self::SESSION_BUSY_MESSAGE, self::findResponseId($input));
264272
} catch (SessionLockException) {
@@ -1138,9 +1146,7 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
11381146
}
11391147

11401148
$session = $this->sessionManager->create();
1141-
if (null !== $principal) {
1142-
$session->set(self::SESSION_PRINCIPAL, self::fingerprint($principal));
1143-
}
1149+
$session->set(self::SESSION_PRINCIPAL, self::fingerprint($principal));
11441150

11451151
$this->logger->debug('Created new session for initialize', [
11461152
'session_id' => $session->getId()->toRfc4122(),
@@ -1183,13 +1189,7 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
11831189
$session = $this->sessionManager->createWithId($sessionId);
11841190

11851191
if (!$this->servesPrincipal($session, $principal)) {
1186-
$this->logger->warning('Refused a request of another principal than the one that opened the session.', [
1187-
'session_id' => $sessionId->toRfc4122(),
1188-
]);
1189-
1190-
// Answered like an unknown session, so that another principal cannot tell the session exists.
1191-
$error = Error::forInvalidRequest('Session not found or has expired.');
1192-
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
1192+
$this->refuseOtherPrincipal($transport, $sessionId);
11931193

11941194
return null;
11951195
}
@@ -1199,12 +1199,26 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
11991199

12001200
/**
12011201
* Whether the session serves the principal: the one that opened it, or no principal for a session opened without one.
1202+
*
1203+
* A session that does not record who opened it, for example because the store failed to read it, serves no one.
12021204
*/
12031205
private function servesPrincipal(SessionInterface $session, ?string $principal): bool
12041206
{
1205-
$fingerprint = $session->get(self::SESSION_PRINCIPAL);
1207+
return $session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal);
1208+
}
1209+
1210+
/**
1211+
* @param TransportInterface<mixed> $transport
1212+
*/
1213+
private function refuseOtherPrincipal(TransportInterface $transport, Uuid $sessionId): void
1214+
{
1215+
$this->logger->warning('Refused a request of another principal than the one that opened the session.', [
1216+
'session_id' => $sessionId->toRfc4122(),
1217+
]);
12061218

1207-
return (\is_string($fingerprint) ? $fingerprint : null) === self::fingerprint($principal);
1219+
// Answered like an unknown session, so that another principal cannot tell the session exists.
1220+
$error = Error::forInvalidRequest('Session not found or has expired.');
1221+
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
12081222
}
12091223

12101224
private static function fingerprint(?string $principal): ?string

‎tests/Unit/Server/BuilderTest.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,14 +222,15 @@ public function testSetSessionLockIsWiredIntoTheProtocol(): void
222222
'{"jsonrpc":"2.0","id":2,"method":"ping"}',
223223
]));
224224

225-
$this->assertCount(12, $log, implode(', ', $log));
225+
$this->assertCount(13, $log, implode(', ', $log));
226226
$sessionId = substr($log[0], \strlen('read '));
227227
$this->assertSame([
228228
// initialize: the session is new, nobody else can know it yet
229229
"read $sessionId",
230230
"read $sessionId",
231231
"write $sessionId",
232-
// ping: locked around read and save
232+
// ping: who opened the session is checked before waiting for the lock, then locked around read and save
233+
"read $sessionId",
233234
"acquire $sessionId",
234235
"read $sessionId",
235236
"read $sessionId",

‎tests/Unit/Server/ProtocolSessionLockTest.php‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingLogger;
3232
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionLock;
3333
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionStore;
34+
use PHPUnit\Framework\Attributes\DataProvider;
3435
use PHPUnit\Framework\Attributes\TestDox;
3536
use PHPUnit\Framework\TestCase;
3637
use Psr\Log\LoggerInterface;
@@ -43,6 +44,8 @@
4344
*/
4445
final class ProtocolSessionLockTest extends TestCase
4546
{
47+
private const INITIALIZE = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}';
48+
4649
/** @var list<string> */
4750
private array $log = [];
4851

@@ -53,7 +56,7 @@ protected function setUp(): void
5356
$this->store = new RecordingSessionStore($this->log);
5457
}
5558

56-
#[TestDox('a request holds the lock from before the session is read until after it is saved')]
59+
#[TestDox('a request holds the lock from before the session is read for handling until after it is saved')]
5760
public function testRequestHoldsTheLockAroundReadAndSave(): void
5861
{
5962
$lock = new RecordingSessionLock($this->log);
@@ -64,6 +67,8 @@ public function testRequestHoldsTheLockAroundReadAndSave(): void
6467
$protocol->processInput($transport, '{"jsonrpc":"2.0","id":1,"method":"ping"}', $sessionId);
6568

6669
$this->assertSame([
70+
// only to check who opened the session
71+
"read $sessionId",
6772
"acquire $sessionId",
6873
"read $sessionId",
6974
// save() reads again to write back only the keys this request changed
@@ -122,7 +127,8 @@ public function testSessionEndedWhileWaitingForTheLockIsRefused(): void
122127
$lock = new RecordingSessionLock($this->log);
123128
$sessionManager = $this->createMock(SessionManagerInterface::class);
124129
$sessionManager->method('exists')->willReturnOnConsecutiveCalls(true, false);
125-
$sessionManager->expects($this->never())->method('createWithId');
130+
// Read before the lock to check who opened the session, and not again once it ended.
131+
$sessionManager->expects($this->once())->method('createWithId');
126132
$protocol = new Protocol([new PingHandler()], [], MessageFactory::make(), $sessionManager, sessionLock: $lock);
127133
$sessionId = Uuid::v4();
128134
$transport = new RecordingTransport();
@@ -148,8 +154,8 @@ public function testRequestWithoutSessionIdTakesNoLock(): void
148154
$this->assertSame(400, $transport->sent[0]['context']['status_code']);
149155
}
150156

151-
#[TestDox('a request whose session is locked by another request is refused before the session is read')]
152-
public function testBusySessionIsRefusedWithoutReadingIt(): void
157+
#[TestDox('a request whose session is locked by another request is refused after reading only who opened the session')]
158+
public function testBusySessionIsRefusedReadingOnlyWhoOpenedIt(): void
153159
{
154160
$lock = new RecordingSessionLock($this->log, acquirable: false);
155161
$protocol = $this->protocol($lock);
@@ -158,7 +164,7 @@ public function testBusySessionIsRefusedWithoutReadingIt(): void
158164

159165
$protocol->processInput($transport, '{"jsonrpc":"2.0","id":1,"method":"ping"}', $sessionId);
160166

161-
$this->assertSame([], $this->log);
167+
$this->assertSame(["read $sessionId"], $this->log);
162168
$this->assertCount(1, $transport->sent);
163169
$this->assertSame(503, $transport->sent[0]['context']['status_code']);
164170

@@ -168,6 +174,34 @@ public function testBusySessionIsRefusedWithoutReadingIt(): void
168174
$this->assertSame('Another request of this session is still being processed.', $error['error']['message']);
169175
}
170176

177+
/**
178+
* @return iterable<string, array{string}>
179+
*/
180+
public static function provideInputsOfAnotherPrincipal(): iterable
181+
{
182+
yield 'a request' => ['{"jsonrpc":"2.0","id":1,"method":"ping"}'];
183+
yield 'an initialize request' => [self::INITIALIZE];
184+
}
185+
186+
#[DataProvider('provideInputsOfAnotherPrincipal')]
187+
#[TestDox('another principal is answered as for an unknown session without waiting for the lock, so it cannot tell the session is busy')]
188+
public function testAnotherPrincipalIsRefusedWithoutWaitingForTheLock(string $input): void
189+
{
190+
$lock = new RecordingSessionLock($this->log);
191+
$protocol = $this->protocol($lock);
192+
$opening = new RecordingTransport();
193+
$protocol->processInput($opening, self::INITIALIZE, null, null, 'alice');
194+
$sessionId = $opening->sent[0]['context']['session_id'];
195+
$lock->acquirable = false;
196+
197+
$unknown = new RecordingTransport();
198+
$protocol->processInput($unknown, $input, Uuid::v4(), null, 'carol');
199+
$transport = new RecordingTransport();
200+
$protocol->processInput($transport, $input, $sessionId, null, 'carol');
201+
202+
$this->assertSame($unknown->sent, $transport->sent);
203+
}
204+
171205
#[TestDox('a busy session is logged as a warning, not as an error')]
172206
public function testBusySessionIsLoggedAsAWarning(): void
173207
{
@@ -191,7 +225,7 @@ public function testUnavailableLockIsRefusedAndLoggedWithItsCause(): void
191225

192226
$protocol->processInput($transport, '{"jsonrpc":"2.0","id":1,"method":"ping"}', $sessionId);
193227

194-
$this->assertSame([], $this->log);
228+
$this->assertSame(["read $sessionId"], $this->log);
195229
$this->assertCount(1, $transport->sent);
196230
$this->assertSame(503, $transport->sent[0]['context']['status_code']);
197231

@@ -616,6 +650,8 @@ private function protocol(RecordingSessionLock $lock, LoggerInterface $logger =
616650
private function existingSession(array $data = []): Uuid
617651
{
618652
$session = new Session($this->store, Uuid::v4());
653+
// Opened without a principal
654+
$session->set('_mcp.principal', null);
619655
foreach ($data as $key => $value) {
620656
$session->set($key, $value);
621657
}

‎tests/Unit/Server/ProtocolSessionRaceTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public function testPollingDoesNotOverwriteAConcurrentResponse(): void
3636
$sessions = new SessionManager($store, gcProbability: 0);
3737
$sessionId = Uuid::v4();
3838
$session = $sessions->createWithId($sessionId);
39+
$session->set('_mcp.principal', null);
3940
$session->set('_mcp.pending_requests', ['00000000-0000-4000-8000-000000000007' => ['request_id' => '00000000-0000-4000-8000-000000000007', 'timeout' => 120, 'timestamp' => time()]]);
4041
$session->save();
4142

@@ -62,6 +63,7 @@ public function testPollingATimedOutRequestDoesNotOverwriteAConcurrentResponse()
6263
$sessions = new SessionManager($store, gcProbability: 0);
6364
$sessionId = Uuid::v4();
6465
$session = $sessions->createWithId($sessionId);
66+
$session->set('_mcp.principal', null);
6567
$session->set('_mcp.pending_requests', [
6668
'00000000-0000-4000-8000-000000000007' => ['request_id' => '00000000-0000-4000-8000-000000000007', 'timeout' => 5, 'timestamp' => time() - 10],
6769
'00000000-0000-4000-8000-000000000008' => ['request_id' => '00000000-0000-4000-8000-000000000008', 'timeout' => 120, 'timestamp' => time()],

0 commit comments

Comments
 (0)