Skip to content

Commit 7ccc3a7

Browse files
committed
[Server] Tell a session without attributes from a failed read and another principal
Treat a zero-byte session file as gone, keep isUnreadable() right after clear() or hydrate(), and log why a session is refused.
1 parent a6cc0dd commit 7ccc3a7

8 files changed

Lines changed: 185 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
4848
* [BC Break] Reject `tools/call` when `arguments` is not a JSON object with `-32600`, including `"arguments": []`.
4949
* [BC Break] Reject `tools/call` with `-32600` when its message has a member name starting with a NUL byte, as its arguments cannot keep their JSON types then.
5050
* [BC Break] `array<string, T>` parameters generate `{"type": "object"}` instead of `{"type": "array"}`, and `array<int, T>` parameters accept a JSON array or a JSON object.
51-
* [BC Break] Add `SessionInterface::isUnreadable()`, which a custom session implements. A session the store holds without attributes, because a handler cleared it or its data is corrupt, is answered with `404` so the client opens a new one; only a session the store fails to return is answered with `503`.
51+
* [BC Break] Add `SessionInterface::isUnreadable()`, which a custom session implements. A session the store holds without attributes, because a handler cleared it or its data is corrupt, is answered with `404` so the client opens a new one; only a session the store fails to return is answered with `503`. `FileSessionStore::exists()` no longer reports a zero-byte file.
5252

5353
### Client
5454

‎src/Server/Protocol.php‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,16 @@ private function doProcessInput(TransportInterface $transport, string $input, ?U
266266
// Who opened the session is checked before waiting for the lock too, so that another
267267
// principal can neither tell the session is busy nor keep it busy.
268268
try {
269-
$serves = $this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal);
269+
$session = $this->sessionManager->createWithId($sessionId);
270+
$serves = $this->servesPrincipal($session, $principal);
270271
} catch (SessionStoreException $e) {
271272
$this->refuseUnavailableStore($transport, $sessionId, self::findResponseId($input), $e);
272273

273274
return;
274275
}
275276

276277
if (!$serves) {
277-
$this->refuseOtherPrincipal($transport, $sessionId);
278+
$this->refuseOtherPrincipal($transport, $session);
278279

279280
return;
280281
}
@@ -1191,7 +1192,7 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
11911192
}
11921193

11931194
if (!$serves) {
1194-
$this->refuseOtherPrincipal($transport, $sessionId);
1195+
$this->refuseOtherPrincipal($transport, $session);
11951196

11961197
return null;
11971198
}
@@ -1236,17 +1237,26 @@ private function refuseUnavailableStore(TransportInterface $transport, Uuid $ses
12361237
/**
12371238
* @param TransportInterface<mixed> $transport
12381239
*/
1239-
private function refuseOtherPrincipal(TransportInterface $transport, Uuid $sessionId): void
1240+
private function refuseOtherPrincipal(TransportInterface $transport, SessionInterface $session): void
12401241
{
1241-
$this->logger->warning('Refused a request of another principal than the one that opened the session.', [
1242-
'session_id' => $sessionId->toRfc4122(),
1243-
]);
1242+
$this->logRefusal('a request', $session);
12441243

12451244
// Answered like an unknown session, so that another principal cannot tell the session exists.
12461245
$error = Error::forInvalidRequest(self::SESSION_NOT_FOUND_MESSAGE);
12471246
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
12481247
}
12491248

1249+
private function logRefusal(string $what, SessionInterface $session): void
1250+
{
1251+
$reason = $session->has(self::SESSION_PRINCIPAL)
1252+
? 'the session was opened by another principal.'
1253+
: 'the session does not record who opened it, so it was cleared, is corrupt or predates the principal binding.';
1254+
1255+
$this->logger->warning(\sprintf('Refused %s: %s', $what, $reason), [
1256+
'session_id' => $session->getId()->toRfc4122(),
1257+
]);
1258+
}
1259+
12501260
private static function fingerprint(?string $principal): ?string
12511261
{
12521262
return null === $principal ? null : hash('sha256', $principal);
@@ -1273,10 +1283,10 @@ private static function findRequestId(MessageInterface|InvalidInputMessageExcept
12731283
*/
12741284
private function endSession(Uuid $sessionId, ?string $principal = null): bool
12751285
{
1276-
if ($this->sessionManager->exists($sessionId) && !$this->servesPrincipal($this->sessionManager->createWithId($sessionId), $principal)) {
1277-
$this->logger->warning('Refused to end a session for another principal than the one that opened it.', [
1278-
'session_id' => $sessionId->toRfc4122(),
1279-
]);
1286+
$session = $this->sessionManager->createWithId($sessionId);
1287+
1288+
if ($this->sessionManager->exists($sessionId) && !$this->servesPrincipal($session, $principal)) {
1289+
$this->logRefusal('a DELETE', $session);
12801290

12811291
return false;
12821292
}

‎src/Server/Session/FileSessionStore.php‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,25 @@ public function exists(Uuid $id): bool
4747
{
4848
$path = $this->pathFor($id);
4949

50+
clearstatcache(true, $path);
51+
5052
if (!is_file($path)) {
5153
return false;
5254
}
5355

5456
$mtime = @filemtime($path) ?: 0;
5557

56-
return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl;
58+
if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) {
59+
return false;
60+
}
61+
62+
if (0 === @filesize($path)) {
63+
$this->logger->warning('Ignored an empty session file.', ['path' => $path]);
64+
65+
return false;
66+
}
67+
68+
return true;
5769
}
5870

5971
public function read(Uuid $id): string|false

‎src/Server/Session/Session.php‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class Session implements SessionInterface
4040
*/
4141
private bool $stored = false;
4242

43+
/**
44+
* Whether the first read of this session returned nothing.
45+
*/
46+
private bool $nothingRead = false;
47+
4348
/**
4449
* Keys changed since the last save, or null once clear() or hydrate() replaced all of them.
4550
*
@@ -188,7 +193,7 @@ public function isUnreadable(): bool
188193
{
189194
$this->readData();
190195

191-
return !$this->stored && $this->store->exists($this->id);
196+
return $this->nothingRead && !$this->stored && $this->store->exists($this->id);
192197
}
193198

194199
public function hydrate(array $attributes): void
@@ -208,7 +213,13 @@ public function jsonSerialize(): array
208213
*/
209214
private function readData(): array
210215
{
211-
return $this->data ??= $this->load() ?? [];
216+
if (!isset($this->data)) {
217+
$data = $this->load();
218+
$this->nothingRead = null === $data;
219+
$this->data = $data ?? [];
220+
}
221+
222+
return $this->data;
212223
}
213224

214225
/**

‎src/Server/Session/SessionInterface.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function has(string $key): bool;
5454
public function forget(string $key): void;
5555

5656
/**
57-
* Remove all attributes from the session.
57+
* Remove all attributes from the session, including who opened it: once saved, the session serves no one.
5858
*/
5959
public function clear(): void;
6060

‎tests/Unit/Server/Session/FileSessionStoreTest.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,23 @@ public function testHappyPathLogsNothing(): void
283283
$this->assertSame([], $logger->warnings);
284284
}
285285

286+
#[TestDox('does not report a zero-byte session file as existing, and says so')]
287+
public function testZeroByteFileDoesNotExist(): void
288+
{
289+
$logger = new WarningCollectingLogger();
290+
$store = new FileSessionStore($this->directory, logger: $logger);
291+
$id = new UuidV4();
292+
$store->write($id, '{"initialized":true}');
293+
$this->assertTrue($store->exists($id));
294+
295+
$path = $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122();
296+
file_put_contents($path, '');
297+
298+
$this->assertFalse($store->exists($id));
299+
$this->assertSame('Ignored an empty session file.', $logger->warnings[0]['message']);
300+
$this->assertSame($path, $logger->warnings[0]['context']['path']);
301+
}
302+
286303
#[TestDox('reports a zero-byte session file as nothing read, and says so')]
287304
public function testReadReportsZeroByteFileAsNothingRead(): void
288305
{

‎tests/Unit/Server/Session/SessionTest.php‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,62 @@ public function testSessionTheStoreHoldsAndReturnsIsNotUnreadable(): void
368368
}
369369

370370
public function testSessionTheStoreHoldsButFailsToReturnIsUnreadable(): void
371+
{
372+
[$store, $id] = $this->storeFailingToReturnASession();
373+
374+
$this->assertTrue((new Session($store, $id))->isUnreadable());
375+
}
376+
377+
public function testSessionClearedBeforeItsFirstReadIsNotUnreadable(): void
378+
{
379+
$store = new InMemorySessionStore();
380+
$id = Uuid::v4();
381+
$store->write($id, '{"key":"value"}');
382+
$session = new Session($store, $id);
383+
384+
$session->clear();
385+
386+
$this->assertFalse($session->isUnreadable());
387+
}
388+
389+
public function testSessionHydratedBeforeItsFirstReadIsNotUnreadable(): void
390+
{
391+
$store = new InMemorySessionStore();
392+
$id = Uuid::v4();
393+
$store->write($id, '{"key":"value"}');
394+
$session = new Session($store, $id);
395+
396+
$session->hydrate(['other' => 'value']);
397+
398+
$this->assertFalse($session->isUnreadable());
399+
}
400+
401+
public function testSessionClearedAfterAFailedReadStaysUnreadable(): void
402+
{
403+
[$store, $id] = $this->storeFailingToReturnASession();
404+
$session = new Session($store, $id);
405+
$session->all();
406+
407+
$session->clear();
408+
409+
$this->assertTrue($session->isUnreadable());
410+
}
411+
412+
public function testNewSessionIsNotUnreadableOnceSaved(): void
413+
{
414+
$store = new InMemorySessionStore();
415+
$session = new Session($store);
416+
$session->set('key', 'value');
417+
418+
$session->save();
419+
420+
$this->assertFalse($session->isUnreadable());
421+
}
422+
423+
/**
424+
* @return array{InMemorySessionStore, Uuid}
425+
*/
426+
private function storeFailingToReturnASession(): array
371427
{
372428
$store = new class extends InMemorySessionStore {
373429
public function read(Uuid $id): string|false
@@ -378,7 +434,7 @@ public function read(Uuid $id): string|false
378434
$id = Uuid::v4();
379435
$store->write($id, '{"key":"value"}');
380436

381-
$this->assertTrue((new Session($store, $id))->isUnreadable());
437+
return [$store, $id];
382438
}
383439

384440
public function testAllReturnsEmptyArrayForNullPayload(): void

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Mcp\Server\Handler\Request\PingHandler;
2121
use Mcp\Server\Protocol;
2222
use Mcp\Server\RequestContext;
23+
use Mcp\Server\Session\FileSessionStore;
2324
use Mcp\Server\Session\InMemorySessionStore;
2425
use Mcp\Server\Session\Session;
2526
use Mcp\Server\Session\SessionManager;
@@ -29,6 +30,7 @@
2930
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
3031
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
3132
use Mcp\Server\Transport\StreamableHttpTransport;
33+
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingLogger;
3234
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionLock;
3335
use Nyholm\Psr7\Factory\Psr17Factory;
3436
use PHPUnit\Framework\Attributes\DataProvider;
@@ -37,6 +39,8 @@
3739
use Psr\Http\Message\ServerRequestInterface;
3840
use Psr\Http\Server\MiddlewareInterface;
3941
use Psr\Http\Server\RequestHandlerInterface;
42+
use Psr\Log\LoggerInterface;
43+
use Psr\Log\NullLogger;
4044
use Symfony\Component\Uid\Uuid;
4145

4246
/**
@@ -176,6 +180,7 @@ public function testSessionClearedByAHandlerIsNotFound(): void
176180
*/
177181
public static function provideDataWithoutAttributes(): iterable
178182
{
183+
yield 'an empty string' => [''];
179184
yield 'empty' => ['[]'];
180185
yield 'not JSON' => ['{'];
181186
yield 'not a list of attributes' => ['null'];
@@ -190,6 +195,51 @@ public function testSessionWhoseDataHoldsNoAttributesIsNotFound(string $data): v
190195
$this->assertSame(404, $this->call($session->toRfc4122(), 'alice.ide.1')->getStatusCode());
191196
}
192197

198+
public function testZeroByteSessionFileIsNotFound(): void
199+
{
200+
$directory = sys_get_temp_dir().'/mcp-session-binding-'.bin2hex(random_bytes(6));
201+
$server = Server::builder()
202+
->setServerInfo('test', '1.0.0')
203+
->setSession(new FileSessionStore($directory))
204+
->addTool(self::whoami(...), name: 'whoami')
205+
->build();
206+
207+
try {
208+
$session = $this->server($server, $this->request('POST', $this->initializeRequest(), 'alice.ide.1'))->getHeaderLine('Mcp-Session-Id');
209+
file_put_contents($directory.\DIRECTORY_SEPARATOR.$session, '');
210+
211+
$this->assertSame(404, $this->server($server, $this->request('POST', $this->toolCall(), 'alice.ide.1', $session))->getStatusCode());
212+
} finally {
213+
array_map('unlink', glob($directory.'/*') ?: []);
214+
rmdir($directory);
215+
}
216+
}
217+
218+
public function testRefusalOfAnotherPrincipalIsLoggedAsSuch(): void
219+
{
220+
$logger = new RecordingLogger();
221+
$protocol = $this->protocol(logger: $logger);
222+
$session = $this->sessionWithQueuedMessage($protocol);
223+
224+
$this->post($protocol, self::PING, $session, 'carol');
225+
226+
$this->assertSame(['Refused a request: the session was opened by another principal.'], $logger->messagesAt('warning'));
227+
}
228+
229+
public function testRefusalOfASessionThatRecordsNoPrincipalIsNotLoggedAsAnotherPrincipal(): void
230+
{
231+
$logger = new RecordingLogger();
232+
$protocol = $this->protocol(logger: $logger);
233+
$session = Uuid::v4();
234+
$this->store->write($session, '{');
235+
236+
$this->assertSame(404, $this->post($protocol, self::PING, $session, 'alice')->getStatusCode());
237+
$this->assertSame(404, $this->endSession($protocol, $session, 'alice')->getStatusCode());
238+
239+
$reason = 'the session does not record who opened it, so it was cleared, is corrupt or predates the principal binding.';
240+
$this->assertSame(['Refused a request: '.$reason, 'Refused a DELETE: '.$reason], $logger->messagesAt('warning'));
241+
}
242+
193243
public function testSessionFromBeforeTheUpgradeIsNotFound(): void
194244
{
195245
$session = Uuid::v4();
@@ -375,13 +425,14 @@ private function serveWith(ServerRequestInterface $request, array $attributes):
375425
return $this->serve($request, [new PassthroughMiddleware()]);
376426
}
377427

378-
private function protocol(int $gcProbability = 0): Protocol
428+
private function protocol(int $gcProbability = 0, LoggerInterface $logger = new NullLogger()): Protocol
379429
{
380430
return new Protocol(
381431
requestHandlers: [new PingHandler()],
382432
notificationHandlers: [],
383433
messageFactory: MessageFactory::make(),
384434
sessionManager: new SessionManager($this->store, gcProbability: $gcProbability, gcDivisor: 1),
435+
logger: $logger,
385436
);
386437
}
387438

@@ -409,6 +460,16 @@ private function post(Protocol $protocol, string $input, Uuid $session, string $
409460
return $transport->listen();
410461
}
411462

463+
private function endSession(Protocol $protocol, Uuid $session, string $principal): ResponseInterface
464+
{
465+
$request = $this->request('DELETE', '', null, $session->toRfc4122())
466+
->withAttribute(StreamableHttpTransport::PRINCIPAL_ATTRIBUTE, $principal);
467+
$transport = new StreamableHttpTransport($request, $this->factory, $this->factory, middleware: [new PassthroughMiddleware()]);
468+
$protocol->connect($transport);
469+
470+
return $transport->listen();
471+
}
472+
412473
private function server(Server $server, ServerRequestInterface $request): ResponseInterface
413474
{
414475
return $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory, middleware: [$this->authorization()]));

0 commit comments

Comments
 (0)