Skip to content

Latest commit

 

History

History
295 lines (231 loc) · 13.3 KB

sessions.md

File metadata and controls

295 lines (231 loc) · 13.3 KB

Sessions

Sessions belong to the handshake era. Protocol revision 2026-07-28 removed them, along with Mcp-Session-Id, because every request carries what is needed to answer it — see Protocol versions. One server serves both, so the configuration below still applies to the handshake-era clients it answers.

Over Streamable HTTP, a server has sessions only once you configure them with setSession(); without it, it serves handshake-era clients without sessions. Over stdio, the connection has one session, which stays in memory unless setSession() names a store. Configure session storage and lifecycle:

use Mcp\Server\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Psr16SessionStore;
use Mcp\Server\Session\Psr6SessionStore;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Cache\Adapter\RedisAdapter;

// File-based storage
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions'))
    ->build();

// In-memory storage with a custom TTL, for a single long-running process
$server = Server::builder()
    ->setSession(new InMemorySessionStore(3600))
    ->build();

// PSR-6 cache-based storage
// Requires symfony/cache (or any other PSR-6 implementation)
// composer require symfony/cache
$redisAdapter = new RedisAdapter(
    RedisAdapter::createConnection('redis://localhost:6379'),
    'mcp_sessions'
);

$server = Server::builder()
    ->setSession(new Psr6SessionStore(
        cache: $redisAdapter,
        prefix: 'mcp-',
        ttl: 3600
    ))
    ->build();

// PSR-16 cache-based storage, which takes the same arguments
// Requires psr/simple-cache and a PSR-16 implementation
$server = Server::builder()
    ->setSession(new Psr16SessionStore(new Psr16Cache($redisAdapter)))
    ->build();

Sessions over HTTP

With HTTP, PHP often serves each request in a different process (e.g. PHP-FPM workers). InMemorySessionStore keeps sessions in the memory of one process, so the next request doesn't find them. setSession() without a store uses this store. Use a store that all processes share, like FileSessionStore on a shared directory or Psr6SessionStore (or Psr16SessionStore) with a shared cache such as Redis.

FileSessionStore suits a single host. Once requests are spread over several hosts, or sessions number in the tens of thousands, use Psr6SessionStore or Psr16SessionStore with Redis or a similar shared cache: it is shared across hosts, expires sessions on its own and needs no garbage collection (see below). A network-mounted directory adds latency and makes session availability depend on the shared filesystem.

Serving Without Sessions

A session is optional in the handshake era: the specification says a server "MAY assign a session ID at initialization time". A server built without setSession() assigns none, so the servers behind a load balancer need no shared session store or lock to answer handshake-era clients.

Over Streamable HTTP, initialize is then answered without an Mcp-Session-Id header, and every later request is served on a session of its own that is dropped once the request is answered; it implements EphemeralSessionInterface, which a handler can check for. A Mcp-Session-Id the client sends anyway is ignored. GET and DELETE are answered with HTTP 405, as there is no stream to open and no session to end.

What a session carries from one request to the next is gone. This changes what your handlers can do:

  • elicit(), elicitUrl(), sample() and listRoots() of the ClientGateway throw a SessionRequiredException. The client answers them in a POST of its own, and the server cannot match that POST to the waiting request. If your handler doesn't catch the exception, the client gets a -32603 error with its message.
  • A handler that returns an InputRequiredResult gets a -32603 error for the same reason. With withoutInputRequiredShim(), the SDK sends the result as it is.
  • supportsElicitation() and the other checks of the ClientGateway, including supportsExtension(), return false, so your handler can fall back to another result.
  • initialize offers neither resource subscriptions nor logging. resources/subscribe, resources/unsubscribe and logging/setLevel get a -32603 error. The ClientLogger and ClientGateway::log() drop all messages.
  • Protocol::sendRequest() and Protocol::sendNotification() throw a SessionRequiredException, as there is no stream to deliver a message sent outside a request.
  • What a handler sets on the session is gone on the next request, and SessionInterface::save() throws a SessionRequiredException.

RequestContext::getProtocolVersion() returns the revision the MCP-Protocol-Version header names, or 2025-03-26 when the header is absent, as the specification asks. Progress and other notifications you send while a request runs still reach the client on that request's stream. Call setSession() if your server needs the rest.

The modern (2026-07-28) era has no sessions to begin with and is not affected, and neither is stdio, where the connection holds the one session.

Garbage Collection Configuration

The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native session.gc_probability and session.gc_divisor settings. The probability that GC runs on any given request is gcProbability / gcDivisor.

// Default: 1/100 (1% chance per request)
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions'))
    ->build();

// Higher frequency: 1/10 (10% chance per request)
$server = Server::builder()
    ->setSession(
        new FileSessionStore(__DIR__.'/sessions'),
        gcProbability: 1,
        gcDivisor: 10,
    )
    ->build();

// Run GC on every request
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions'), gcProbability: 1, gcDivisor: 1)
    ->build();

// Disable GC entirely (e.g. when using an external cleanup process)
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions'), gcProbability: 0)
    ->build();

Running GC Out of Band:

GC runs inside a request, before the server handles the message. FileSessionStore::gc() scans every file in the session directory, so its cost grows with the number of sessions. On a busy server, a random request then waits for that scan.

In that case, disable GC in the server and call gc() from a cron job or a console command. Use the same directory and TTL as the server:

// bin/session-gc.php, e.g. run every 10 minutes from cron
use Mcp\Server\Session\FileSessionStore;

require __DIR__.'/../vendor/autoload.php';

$store = new FileSessionStore(__DIR__.'/../sessions', ttl: 3600);
$deleted = $store->gc(); // returns the IDs of the deleted sessions

echo count($deleted) . " expired sessions deleted\n";
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions', ttl: 3600), gcProbability: 0)
    ->build();

Psr6SessionStore and Psr16SessionStore don't scan anything. They store each session with the TTL, and their gc() does nothing. With a cache like Redis, the cache expires the keys itself.

Parameters:

  • $gcProbability (int): The numerator of the GC probability fraction (default: 1). Set to 0 to disable GC.
  • $gcDivisor (int): The denominator of the GC probability fraction (default: 100). Must be >= 1.

Note: When providing a custom SessionManagerInterface via the $sessionManager parameter, the gcProbability and gcDivisor settings are ignored — you control GC behavior in your own implementation.

Concurrent Requests

Over HTTP, two requests of one session can run at the same time on different workers: tools/list and resources/list sent together on connect, or parallel tool calls from an agent. Each request loads the session, changes it and writes it back whole, so the last write wins and the other request's changes are gone: a pending request to the client, the client's answer to one, the client info. Responses do not go through the session and are not affected.

A session lock serializes the requests of one session. It is off by default; enable it with setSessionLock() and pair it with the store:

use Mcp\Server\Session\FileSessionLock;
use Mcp\Server\Session\FileSessionStore;

// flock() on one file per session, for the workers of one machine
$server = Server::builder()
    ->setSession(new FileSessionStore(__DIR__.'/sessions'))
    ->setSessionLock(new FileSessionLock(__DIR__.'/sessions'))
    ->build();

Point FileSessionLock at the session directory: FileSessionStore::gc() then removes the lock files of expired sessions along with them. It never removes the file of a lock a request still holds.

For a store shared across machines, use SymfonyLockSessionLock with any symfony/lock store (composer require symfony/lock):

use Mcp\Server\Session\Psr6SessionStore;
use Mcp\Server\Session\SymfonyLockSessionLock;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;

$redis = RedisAdapter::createConnection('redis://localhost:6379');

$server = Server::builder()
    ->setSession(new Psr6SessionStore(new RedisAdapter($redis, 'mcp_sessions')))
    ->setSessionLock(new SymfonyLockSessionLock(new LockFactory(new RedisStore($redis))))
    ->build();

!!! warning With PHP-FPM or Apache mod_php, each request opens new TCP connections to Redis. Under load, the server can run out of local ports. Symfony Cache logs the connection errors and reports a cache miss, so clients get HTTP 404 for sessions that still exist. Use a persistent connection: redis://localhost:6379?persistent=1.

The lock is held from before a request reads its session until it saved it, so while its handlers run. It is released when a handler suspends to wait for the client (elicitation, sampling), so the answer's own request can be processed. The stream that waits for the answer takes the lock briefly on each poll, and only when it is free; otherwise it tries again on its next poll. Parallel tool calls of one session therefore run one after the other.

A request that cannot get the lock within the timeout (30 seconds by default, the constructors' $timeout) is answered with HTTP 503 and a JSON-RPC error, instead of running on a session another request is about to overwrite. A lock backend that fails, e.g. a Redis that refuses connections, is answered with 503 too, but at once, with the error message Session lock is unavailable., and logged as an error with its cause; a timeout is only logged as a warning. A handler that resumed after the client answered gets an error when the lock cannot be had to store its next request or notification: the ClientGateway method throws a ClientException, and the message does not reach the client.

SymfonyLockSessionLock expires a held lock after $ttl (300 seconds by default), so a worker that dies holding one does not block the session for good. The lease is not refreshed while a handler runs, so $ttl must be longer than your longest handler runs; otherwise another request can take the lock before the first one saved the session. A fatal error in a handler (memory limit, max_execution_time) skips the normal release, so SymfonyLockSessionLock also releases its locks from a shutdown function; FileSessionLock needs none, as the operating system drops a flock() with the process.

Custom Session Locks:

Implement SessionLockInterface for another lock backend. acquire() blocks until the lock is held or throws SessionLockTimeoutException after the backend's timeout, and SessionLockException when the backend fails; tryAcquire() takes the lock only if it is free right now and returns false otherwise; release() gives it back.

Available Session Stores:

  • InMemorySessionStore: Fast in-memory storage for a single long-running process
  • FileSessionStore: Persistent file-based storage
  • Psr6SessionStore: PSR-6 compliant cache-based storage
  • Psr16SessionStore: PSR-16 compliant cache-based storage

Custom Session Stores:

Implement SessionStoreInterface to create custom session storage:

use Mcp\Server\Session\SessionStoreInterface;
use Symfony\Component\Uid\Uuid;

class RedisSessionStore implements SessionStoreInterface
{
    public function __construct(private $redis, private int $ttl = 3600) {}

    public function exists(Uuid $id): bool
    {
        return $this->redis->exists($id->toRfc4122());
    }

    public function read(Uuid $sessionId): string|false
    {
        $data = $this->redis->get($sessionId->toRfc4122());
        return $data !== false ? $data : false;
    }

    public function write(Uuid $sessionId, string $data): bool
    {
        return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data);
    }

    public function destroy(Uuid $sessionId): bool
    {
        return $this->redis->del($sessionId->toRfc4122()) > 0;
    }

    public function gc(): array
    {
        // Redis handles TTL automatically
        return [];
    }
}

Throw a SessionStoreException from exists() when the backend cannot be reached. The client then gets a 503.