Skip to content

Commit c26cf5c

Browse files
[Server] Answer a DELETE of an unknown or ended session with 404 (#638)
* [Server] Answer a DELETE of an unknown or ended session with 404 The spec requires a 404 for any request that carries the id of a terminated session. * Make InMemorySessionStore::exists() honor the TTL
1 parent 9a7fea0 commit c26cf5c

9 files changed

Lines changed: 93 additions & 12 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ All notable changes to `mcp/sdk` will be documented in this file.
88
### Server
99

1010
* [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is
11+
* [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`.
1112
* Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`).
1213
* Fix `array<string|null>` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`.
14+
* Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone.
1315
* Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds.
1416
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.
1517

‎src/Server/Protocol.php‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,14 +1191,23 @@ private static function findRequestId(array $messages): string|int|null
11911191

11921192
/**
11931193
* Destroy a specific session.
1194+
*
1195+
* @return bool false when the session does not exist
11941196
*/
1195-
public function destroySession(Uuid $sessionId): void
1197+
public function destroySession(Uuid $sessionId): bool
11961198
{
1199+
try {
1200+
$exists = $this->sessionManager->exists($sessionId);
1201+
} catch (SessionStoreException) {
1202+
// The session may well exist, so it is not reported as unknown.
1203+
$exists = true;
1204+
}
1205+
11971206
// Destroyed even when a request of the session holds the lock past the timeout, or the
11981207
// lock is unavailable: the client ended the session, and Session::save() does not write
11991208
// back a session that was destroyed since it was read. An unknown session id has nothing to lock.
12001209
try {
1201-
$locked = null !== $this->sessionLock && $this->sessionManager->exists($sessionId) && $this->acquireSessionLock($sessionId);
1210+
$locked = null !== $this->sessionLock && $exists && $this->acquireSessionLock($sessionId);
12021211
} catch (SessionLockException) {
12031212
$locked = false;
12041213
}
@@ -1212,6 +1221,8 @@ public function destroySession(Uuid $sessionId): void
12121221
}
12131222

12141223
$this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]);
1224+
1225+
return $exists;
12151226
}
12161227

12171228
/**

‎src/Server/Session/InMemorySessionStore.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ public function __construct(
3030

3131
public function exists(Uuid $id): bool
3232
{
33-
return isset($this->store[$id->toRfc4122()]);
33+
$session = $this->store[$id->toRfc4122()] ?? null;
34+
35+
return null !== $session && $this->clock->now()->getTimestamp() - $session['timestamp'] <= $this->ttl;
3436
}
3537

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

‎src/Server/Transport/BaseTransport.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,15 @@ protected function handleMessage(string $payload, ?Uuid $sessionId, ?AccessToken
152152
}
153153
}
154154

155-
protected function handleSessionEnd(?Uuid $sessionId): void
155+
/**
156+
* @return bool false when the listener reported that the session does not exist
157+
*/
158+
protected function handleSessionEnd(?Uuid $sessionId): bool
156159
{
157160
if ($sessionId && \is_callable($this->sessionEndListener)) {
158-
($this->sessionEndListener)($sessionId);
161+
return false !== ($this->sessionEndListener)($sessionId);
159162
}
163+
164+
return true;
160165
}
161166
}

‎src/Server/Transport/ManagesTransportCallbacks.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ trait ManagesTransportCallbacks
3131
/** @var callable(TransportInterface<mixed>, string, ?Uuid, ?AccessToken): void */
3232
protected $messageListener;
3333

34-
/** @var callable(Uuid): void */
34+
/** @var callable(Uuid): bool */
3535
protected $sessionEndListener;
3636

3737
/** @var callable(Uuid): array<int, array{message: string, context: array<string, mixed>}> */

‎src/Server/Transport/StreamableHttpTransport.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ protected function handleDeleteRequest(): ResponseInterface
242242
return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header is required.'), 400);
243243
}
244244

245-
$this->handleSessionEnd($this->sessionId);
245+
if (!$this->handleSessionEnd($this->sessionId)) {
246+
return $this->createErrorResponse(Error::forInvalidRequest('Session not found or has expired.'), 404);
247+
}
246248

247249
return $this->responseFactory->createResponse(200);
248250
}

‎src/Server/Transport/TransportInterface.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function onMessage(callable $listener): void;
8383
*
8484
* The transport calls this when a client disconnects or explicitly ends their session.
8585
*
86-
* @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session
86+
* @param callable(Uuid $sessionId): bool $listener The callback function to execute when destroying a session, returning false when the session does not exist
8787
*/
8888
public function onSessionEnd(callable $listener): void;
8989

‎tests/Unit/Server/ProtocolSessionLockTest.php‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,7 @@ public function testDestroyingASessionLocks(): void
416416
$protocol = $this->protocol($lock);
417417
$sessionId = $this->existingSession();
418418

419-
$protocol->destroySession($sessionId);
420-
419+
$this->assertTrue($protocol->destroySession($sessionId));
421420
$this->assertSame(["acquire $sessionId", "destroy $sessionId", "release $sessionId"], $this->log);
422421
$this->assertFalse($this->store->exists($sessionId));
423422
}
@@ -442,8 +441,7 @@ public function testDestroyingAnUnknownSessionTakesNoLock(): void
442441
$protocol = $this->protocol($lock);
443442
$sessionId = Uuid::v4();
444443

445-
$protocol->destroySession($sessionId);
446-
444+
$this->assertFalse($protocol->destroySession($sessionId));
447445
$this->assertSame(["destroy $sessionId"], $this->log);
448446
}
449447

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Mcp\Server;
2020
use Mcp\Server\RequestContext;
2121
use Mcp\Server\Session\FileSessionLock;
22+
use Mcp\Server\Session\InMemorySessionStore;
2223
use Mcp\Server\Session\Session;
2324
use Mcp\Server\Session\SessionLockInterface;
2425
use Mcp\Server\Suspension\RequestSuspension;
@@ -34,6 +35,7 @@
3435
use PHPUnit\Framework\Attributes\DataProvider;
3536
use PHPUnit\Framework\Attributes\TestDox;
3637
use PHPUnit\Framework\TestCase;
38+
use Psr\Clock\ClockInterface;
3739
use Psr\Http\Message\ResponseFactoryInterface;
3840
use Psr\Http\Message\ResponseInterface;
3941
use Psr\Http\Message\ServerRequestInterface;
@@ -149,6 +151,40 @@ public function testDuplicateSessionIdHeadersReturnBadRequest(): void
149151
$this->assertStringContainsString('must not be repeated', (string) $response->getBody());
150152
}
151153

154+
#[TestDox('a DELETE of an unknown or already ended session is answered 404')]
155+
public function testDeleteOfAnEndedSessionIsNotFound(): void
156+
{
157+
$server = Server::builder()->setServerInfo('test', '1.0')->build();
158+
$sessionId = $this->initialize($server);
159+
160+
$this->assertSame(200, $this->delete($server, $sessionId)->getStatusCode());
161+
162+
$response = $this->delete($server, $sessionId);
163+
164+
$this->assertSame(404, $response->getStatusCode());
165+
$this->assertStringContainsString('Session not found', (string) $response->getBody());
166+
}
167+
168+
#[TestDox('a DELETE of an expired session is answered 404')]
169+
public function testDeleteOfAnExpiredSessionIsNotFound(): void
170+
{
171+
$clock = new class implements ClockInterface {
172+
public \DateTimeImmutable $now;
173+
174+
public function now(): \DateTimeImmutable
175+
{
176+
return $this->now;
177+
}
178+
};
179+
$clock->now = new \DateTimeImmutable('2026-10-28 12:00:00');
180+
$server = Server::builder()->setServerInfo('test', '1.0')->setSession(new InMemorySessionStore(60, $clock))->build();
181+
$sessionId = $this->initialize($server);
182+
183+
$clock->now = $clock->now->modify('+61 seconds');
184+
185+
$this->assertSame(404, $this->delete($server, $sessionId)->getStatusCode());
186+
}
187+
152188
#[TestDox('pass-through middleware disables defaults without a warning log')]
153189
public function testPassthroughMiddlewareDisablesDefaultsWithoutWarning(): void
154190
{
@@ -841,6 +877,31 @@ private function post(InterleavingSessionStore $store, string $body, string $ses
841877
return $builder->build()->run(new StreamableHttpTransport($request, $this->factory, $this->factory));
842878
}
843879

880+
/**
881+
* Opens a session on $server and returns its id.
882+
*/
883+
private function initialize(Server $server): string
884+
{
885+
$request = $this->factory
886+
->createServerRequest('POST', 'http://localhost/')
887+
->withHeader('Host', 'localhost')
888+
->withHeader('Content-Type', 'application/json')
889+
->withHeader('Accept', 'application/json, text/event-stream')
890+
->withBody($this->factory->createStream('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'));
891+
892+
return $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory))->getHeaderLine(StreamableHttpTransport::SESSION_HEADER);
893+
}
894+
895+
private function delete(Server $server, string $sessionId): ResponseInterface
896+
{
897+
$request = $this->factory
898+
->createServerRequest('DELETE', 'http://localhost/')
899+
->withHeader('Host', 'localhost')
900+
->withHeader(StreamableHttpTransport::SESSION_HEADER, $sessionId);
901+
902+
return $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory));
903+
}
904+
844905
/**
845906
* Reads an SSE response; the stream calls ob_flush() itself, so the output is captured by a handler, not a plain buffer.
846907
*/

0 commit comments

Comments
 (0)