Sessions belong to the handshake era. Protocol revision
2026-07-28removed them, along withMcp-Session-Id, because every request carries what is needed to answer it — see The 2026-07-28 lifecycle. One server serves both, so the configuration below still applies to the handshake-era clients it answers.
Configure session storage and lifecycle. By default, the SDK uses InMemorySessionStore:
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Psr16SessionStore;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Cache\Adapter\RedisAdapter;
// Override with file-based storage
$server = Server::builder()
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
->build();
// Override with in-memory storage and custom TTL
$server = Server::builder()
->setSession(new InMemorySessionStore(3600))
->build();
// Override with PSR-16 cache-based storage
// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation)
// composer require psr/simple-cache symfony/cache
$redisAdapter = new RedisAdapter(
RedisAdapter::createConnection('redis://localhost:6379'),
'mcp_sessions'
);
$server = Server::builder()
->setSession(new Psr16SessionStore(
cache: new Psr16Cache($redisAdapter),
prefix: 'mcp-',
ttl: 3600
))
->build();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. Use a store that all processes share, like FileSessionStore on a shared
directory 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 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.
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(gcProbability: 1, gcDivisor: 1)
->build();
// Disable GC entirely (e.g. when using an external cleanup process)
$server = Server::builder()
->setSession(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();Psr16SessionStore doesn't scan anything: it stores each session with the TTL, and its 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 to0to disable GC.$gcDivisor(int): The denominator of the GC probability fraction (default:100). Must be >= 1.
Note: When providing a custom
SessionManagerInterfacevia the$sessionManagerparameter, thegcProbabilityandgcDivisorsettings are ignored — you control GC behavior in your own implementation.
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\Psr16SessionStore;
use Mcp\Server\Session\SymfonyLockSessionLock;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
$redis = RedisAdapter::createConnection('redis://localhost:6379');
$server = Server::builder()
->setSession(new Psr16SessionStore(new Psr16Cache(new RedisAdapter($redis, 'mcp_sessions'))))
->setSessionLock(new SymfonyLockSessionLock(new LockFactory(new RedisStore($redis))))
->build();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 (default)FileSessionStore: Persistent file-based storagePsr16SessionStore: 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 [];
}
}