Skip to content

Commit a6cc0dd

Browse files
committed
[Server] Answer 404 for a session that holds no attributes
1 parent 0c184ad commit a6cc0dd

6 files changed

Lines changed: 84 additions & 2 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`.
5152

5253
### Client
5354

‎src/Server/Protocol.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,15 +1204,15 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
12041204
*
12051205
* A session that does not record who opened it serves no one.
12061206
*
1207-
* @throws SessionStoreException when the session exists but its data cannot be read
1207+
* @throws SessionStoreException when the store holds the session but fails to return it
12081208
*/
12091209
private function servesPrincipal(SessionInterface $session, ?string $principal): bool
12101210
{
12111211
if ($session->get(self::SESSION_PRINCIPAL, false) === self::fingerprint($principal)) {
12121212
return true;
12131213
}
12141214

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

‎src/Server/Session/Session.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ public function all(): array
184184
return $this->readData();
185185
}
186186

187+
public function isUnreadable(): bool
188+
{
189+
$this->readData();
190+
191+
return !$this->stored && $this->store->exists($this->id);
192+
}
193+
187194
public function hydrate(array $attributes): void
188195
{
189196
$this->data = $attributes;

‎src/Server/Session/SessionInterface.php‎

Lines changed: 8 additions & 0 deletions
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
/**
@@ -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/SessionTest.php‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,34 @@ 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 = new class extends InMemorySessionStore {
373+
public function read(Uuid $id): string|false
374+
{
375+
return false;
376+
}
377+
};
378+
$id = Uuid::v4();
379+
$store->write($id, '{"key":"value"}');
380+
381+
$this->assertTrue((new Session($store, $id))->isUnreadable());
382+
}
383+
356384
public function testAllReturnsEmptyArrayForNullPayload(): void
357385
{
358386
$store = $this->getMockBuilder(InMemorySessionStore::class)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ protected function setUp(): void
5959
->setServerInfo('test', '1.0.0')
6060
->setSession($this->store)
6161
->addTool(self::whoami(...), name: 'whoami')
62+
->addTool(self::forget(...), name: 'forget')
6263
->build();
6364
}
6465

@@ -159,6 +160,36 @@ public function testDeleteDuringAStoreOutageKeepsTheSession(): void
159160
$this->assertSame('alice', $this->text($this->call($session, 'alice.ide.1')));
160161
}
161162

163+
public function testSessionClearedByAHandlerIsNotFound(): void
164+
{
165+
$session = $this->initialize('alice.ide.1');
166+
$forget = json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'forget', 'arguments' => new \stdClass()]], \JSON_THROW_ON_ERROR);
167+
168+
$this->assertSame(200, $this->serve($this->request('POST', $forget, 'alice.ide.1', $session))->getStatusCode());
169+
170+
$this->assertSame(404, $this->call($session, 'alice.ide.1')->getStatusCode());
171+
$this->assertSame(404, $this->serve($this->request('DELETE', '', 'alice.ide.1', $session))->getStatusCode());
172+
}
173+
174+
/**
175+
* @return iterable<string, array{string}>
176+
*/
177+
public static function provideDataWithoutAttributes(): iterable
178+
{
179+
yield 'empty' => ['[]'];
180+
yield 'not JSON' => ['{'];
181+
yield 'not a list of attributes' => ['null'];
182+
}
183+
184+
#[DataProvider('provideDataWithoutAttributes')]
185+
public function testSessionWhoseDataHoldsNoAttributesIsNotFound(string $data): void
186+
{
187+
$session = Uuid::v4();
188+
$this->store->write($session, $data);
189+
190+
$this->assertSame(404, $this->call($session->toRfc4122(), 'alice.ide.1')->getStatusCode());
191+
}
192+
162193
public function testSessionFromBeforeTheUpgradeIsNotFound(): void
163194
{
164195
$session = Uuid::v4();
@@ -301,6 +332,13 @@ public function testSessionOpenedWithoutPrincipalOnlyServesRequestsWithoutOne():
301332
$this->assertSame(404, $this->call($session, 'alice.ide.1')->getStatusCode());
302333
}
303334

335+
public static function forget(RequestContext $context): string
336+
{
337+
$context->getSession()->clear();
338+
339+
return 'forgotten';
340+
}
341+
304342
public static function whoami(RequestContext $context): string
305343
{
306344
return $context->getAccessToken()?->getSubject() ?? 'anonymous';

0 commit comments

Comments
 (0)