Skip to content

Commit a4bad64

Browse files
authored
[Server] Answer 404 for a session that a handler cleared or that holds corrupt data (#682)
* [Server] Answer 404 for a session that holds no attributes * [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 cba0121 commit a4bad64

8 files changed

Lines changed: 266 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +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`. `FileSessionStore::exists()` no longer reports a zero-byte file.
5152

5253
### Client
5354

‎src/Server/Protocol.php‎

Lines changed: 23 additions & 13 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
}
@@ -1204,15 +1205,15 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
12041205
*
12051206
* A session that does not record who opened it serves no one.
12061207
*
1207-
* @throws SessionStoreException when the session exists but its data cannot be read
1208+
* @throws SessionStoreException when the store holds the session but fails to return it
12081209
*/
12091210
private function servesPrincipal(SessionInterface $session, ?string $principal): bool
12101211
{
12111212
if ($session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal)) {
12121213
return true;
12131214
}
12141215

1215-
if ([] === $session->all()) {
1216+
if ($session->isUnreadable()) {
12161217
throw new SessionStoreException(\sprintf('Session "%s" could not be read.', $session->getId()->toRfc4122()));
12171218
}
12181219

@@ -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: 19 additions & 1 deletion
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
*
@@ -184,6 +189,13 @@ public function all(): array
184189
return $this->readData();
185190
}
186191

192+
public function isUnreadable(): bool
193+
{
194+
$this->readData();
195+
196+
return $this->nothingRead && !$this->stored && $this->store->exists($this->id);
197+
}
198+
187199
public function hydrate(array $attributes): void
188200
{
189201
$this->data = $attributes;
@@ -201,7 +213,13 @@ public function jsonSerialize(): array
201213
*/
202214
private function readData(): array
203215
{
204-
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;
205223
}
206224

207225
/**

‎src/Server/Session/SessionInterface.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Server\Session;
1313

14+
use Mcp\Exception\SessionStoreException;
1415
use Symfony\Component\Uid\Uuid;
1516

1617
/**
@@ -53,7 +54,7 @@ public function has(string $key): bool;
5354
public function forget(string $key): void;
5455

5556
/**
56-
* Remove all attributes from the session.
57+
* Remove all attributes from the session, including who opened it: once saved, the session serves no one.
5758
*/
5859
public function clear(): void;
5960

@@ -70,6 +71,13 @@ public function pull(string $key, mixed $default = null): mixed;
7071
*/
7172
public function all(): array;
7273

74+
/**
75+
* Whether the store holds this session but failed to return it, unlike a session without attributes.
76+
*
77+
* @throws SessionStoreException if the store cannot be reached
78+
*/
79+
public function isUnreadable(): bool;
80+
7381
/**
7482
* Set all attributes of the session, typically for hydration.
7583
* This will overwrite existing attributes.

‎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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,90 @@ public function testSaveBeforeReadInitializesData(): void
353353
$this->assertTrue($session->save());
354354
}
355355

356+
public function testNewSessionIsNotUnreadable(): void
357+
{
358+
$this->assertFalse((new Session(new InMemorySessionStore()))->isUnreadable());
359+
}
360+
361+
public function testSessionTheStoreHoldsAndReturnsIsNotUnreadable(): void
362+
{
363+
$store = new InMemorySessionStore();
364+
$id = Uuid::v4();
365+
$store->write($id, '[]');
366+
367+
$this->assertFalse((new Session($store, $id))->isUnreadable());
368+
}
369+
370+
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
427+
{
428+
$store = new class extends InMemorySessionStore {
429+
public function read(Uuid $id): string|false
430+
{
431+
return false;
432+
}
433+
};
434+
$id = Uuid::v4();
435+
$store->write($id, '{"key":"value"}');
436+
437+
return [$store, $id];
438+
}
439+
356440
public function testAllReturnsEmptyArrayForNullPayload(): void
357441
{
358442
$store = $this->getMockBuilder(InMemorySessionStore::class)

0 commit comments

Comments
 (0)