Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/run/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

Over Streamable HTTP, a server has sessions only once you configure them with `setSession()`; without it, it
serves handshake-era clients [without sessions](#serving-without-sessions). Over stdio, the connection has one
session, which stays in memory unless `setSession()` names a store. Configure session storage and lifecycle:
session, which stays in memory until the connection closes, without an idle timeout. Calling `setSession()`
opts into the configured session lifecycle on stdio too: without a store it uses the one-hour in-memory
default, and a supplied store keeps its own expiry policy. Configure session storage and lifecycle:

```php
use Mcp\Server\Server;
Expand Down
2 changes: 1 addition & 1 deletion src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,7 @@ private function resolve(): array
: $this->eventDispatcher;
$subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger);
$sessionManager = $this->sessionManager ?? new SessionManager(
$this->sessionStore ?? new InMemorySessionStore(),
$this->sessionStore ?? ($this->sessions ? new InMemorySessionStore() : new InMemorySessionStore(ttl: \PHP_INT_MAX)),
$logger,
$this->gcProbability,
$this->gcDivisor,
Expand Down
94 changes: 94 additions & 0 deletions tests/Unit/Server/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@
use Mcp\Server\Handler\Request\InitializeHandler;
use Mcp\Server\Protocol;
use Mcp\Server\Server;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Session\SessionManager;
use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Subscription\InMemoryNotificationBus;
use Mcp\Server\Subscription\PublishingEventDispatcher;
use Mcp\Server\Transport\InMemoryTransport;
use Mcp\Server\Transport\StdioTransport;
use Mcp\Tests\Unit\Server\Extension\ThingExtension;
use Mcp\Tests\Unit\Server\Extension\ThingListHandler;
use Mcp\Tests\Unit\Server\Extension\ThingListRequest;
Expand Down Expand Up @@ -224,6 +227,97 @@ public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void
$this->assertTrue($capabilities->tools);
}

#[TestDox('the default stdio session survives idle time and is destroyed at EOF')]
public function testDefaultStdioSessionSurvivesIdleTime(): void
{
$server = Server::builder()->build();
$store = $this->extractSessionStore($server);
$records = new \ReflectionProperty(InMemorySessionStore::class, 'store');
$input = fopen('php://memory', 'r+');
$this->assertNotFalse($input);
fwrite($input, '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'."\n");
fwrite($input, '{"jsonrpc":"2.0","id":2,"method":"ping"}'."\n");
rewind($input);
$outputFile = tempnam(sys_get_temp_dir(), 'mcp-idle');
$this->assertNotFalse($outputFile);
$output = fopen($outputFile, 'w');
$this->assertNotFalse($output);
$transport = new class($input, $output, $store, $records) extends StdioTransport {
/**
* @param resource $input
* @param resource $output
*/
public function __construct($input, $output, private readonly InMemorySessionStore $store, private readonly \ReflectionProperty $records)
{
parent::__construct($input, $output);
}

private int $ticks = 0;

protected function processInput(): void
{
if (1 !== $this->ticks++) {
parent::processInput();

return;
}

/** @var array<string, array{data: string, timestamp: int}> $records */
$records = $this->records->getValue($this->store);
foreach ($records as &$record) {
$record['timestamp'] -= 7200;
}
$this->records->setValue($this->store, $records);
parent::processInput();
}
};

try {
$this->assertSame(0, $server->run($transport));
$written = file_get_contents($outputFile);
$this->assertNotFalse($written);
$lines = array_map(static fn (string $line): array => json_decode($line, true, flags: \JSON_THROW_ON_ERROR), explode("\n", trim($written)));
$this->assertSame(1, $lines[0]['id'] ?? null);
$this->assertArrayHasKey('result', $lines[0]);
$this->assertSame(2, $lines[1]['id'] ?? null, $written);
$this->assertSame([], $lines[1]['result'] ?? null);
$this->assertSame([], $records->getValue($store));
} finally {
unlink($outputFile);
}
}

#[TestDox('explicitly enabling sessions preserves the default HTTP expiry')]
public function testExplicitSessionsStillExpire(): void
{
$store = $this->extractSessionStore(Server::builder()->setSession()->build());
$id = \Symfony\Component\Uid\Uuid::v4();
$store->write($id, '{}');
$records = new \ReflectionProperty(InMemorySessionStore::class, 'store');
$records->setValue($store, [$id->toRfc4122() => ['data' => '{}', 'timestamp' => time() - 7200]]);

$this->assertFalse($store->exists($id));
$this->assertEquals([$id], $store->gc());
}

#[TestDox('a configured session store is retained')]
public function testCustomSessionStoreIsRetained(): void
{
$store = new InMemorySessionStore(ttl: 2);

$this->assertSame($store, $this->extractSessionStore(Server::builder()->setSession($store)->build()));
}

private function extractSessionStore(Server $server): InMemorySessionStore
{
$protocol = (new \ReflectionProperty(Server::class, 'protocol'))->getValue($server);
$manager = (new \ReflectionProperty(Protocol::class, 'sessionManager'))->getValue($protocol);
$store = (new \ReflectionProperty(SessionManager::class, 'store'))->getValue($manager);
$this->assertInstanceOf(InMemorySessionStore::class, $store);

return $store;
}

#[TestDox('setSessionLock() returns the builder for fluent chaining')]
public function testSetSessionLockReturnsSelf(): void
{
Expand Down
Loading