Encapsulate session handling from protocol to new SessionManager (#247)

This commit is contained in:
Christopher Hertel 2026-03-10 18:09:15 +01:00 • committed by GitHub
commit ae320646bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 444 additions and 200 deletions

View file

@ -23,6 +23,7 @@ use Mcp\Capability\Registry\Loader\DiscoveryLoader;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\InvalidArgumentException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Annotations;
use Mcp\Schema\Enum\ProtocolVersion;
@ -36,8 +37,8 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Resource\SessionSubscriptionManager;
use Mcp\Server\Resource\SubscriptionManagerInterface;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\SessionFactory;
use Mcp\Server\Session\SessionFactoryInterface;
use Mcp\Server\Session\SessionManager;
use Mcp\Server\Session\SessionManagerInterface;
use Mcp\Server\Session\SessionStoreInterface;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
@ -70,12 +71,10 @@ final class Builder
private ?DiscovererInterface $discoverer = null;
private ?SessionFactoryInterface $sessionFactory = null;
private ?SessionManagerInterface $sessionManager = null;
private ?SessionStoreInterface $sessionStore = null;
private int $sessionTtl = 3600;
private int $paginationLimit = 50;
private ?string $instructions = null;
@ -321,13 +320,15 @@ final class Builder
}
public function setSession(
SessionStoreInterface $sessionStore,
SessionFactoryInterface $sessionFactory = new SessionFactory(),
int $ttl = 3600,
?SessionStoreInterface $sessionStore = null,
?SessionManagerInterface $sessionManager = null,
): self {
$this->sessionFactory = $sessionFactory;
$this->sessionStore = $sessionStore;
$this->sessionTtl = $ttl;
$this->sessionManager = $sessionManager;
if (null !== $sessionManager && null !== $sessionStore) {
throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.');
}
return $this;
}
@ -506,9 +507,10 @@ final class Builder
new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator),
];
$sessionTtl = $this->sessionTtl ?? 3600;
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
$sessionManager = $this->sessionManager ?? new SessionManager(
$this->sessionStore ?? new InMemorySessionStore(),
$logger,
);
if (null !== $this->discoveryBasePath) {
$discoverer = $this->discoverer ?? $this->createDiscoverer($logger);
@ -561,8 +563,7 @@ final class Builder
requestHandlers: $requestHandlers,
notificationHandlers: $notificationHandlers,
messageFactory: $messageFactory,
sessionFactory: $sessionFactory,
sessionStore: $sessionStore,
sessionManager: $sessionManager,
logger: $logger,
eventDispatcher: $this->eventDispatcher,
);

View file

@ -25,9 +25,8 @@ use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Request\InitializeRequest;
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Session\SessionFactoryInterface;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Session\SessionStoreInterface;
use Mcp\Server\Session\SessionManagerInterface;
use Mcp\Server\Transport\TransportInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
@ -70,8 +69,7 @@ class Protocol
private readonly array $requestHandlers,
private readonly array $notificationHandlers,
private readonly MessageFactory $messageFactory,
private readonly SessionFactoryInterface $sessionFactory,
private readonly SessionStoreInterface $sessionStore,
private readonly SessionManagerInterface $sessionManager,
private readonly LoggerInterface $logger = new NullLogger(),
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
@ -112,7 +110,7 @@ class Protocol
{
$this->logger->info('Received message to process.', ['message' => $input]);
$this->gcSessions();
$this->sessionManager->gc();
try {
$messages = $this->messageFactory->create($input);
@ -410,7 +408,7 @@ class Protocol
*/
public function consumeOutgoingMessages(Uuid $sessionId): array
{
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$session = $this->sessionManager->createWithId($sessionId);
$queue = $session->get(self::SESSION_OUTGOING_QUEUE, []);
$session->set(self::SESSION_OUTGOING_QUEUE, []);
$session->save();
@ -429,7 +427,7 @@ class Protocol
*/
public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null
{
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$session = $this->sessionManager->createWithId($sessionId);
$responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}");
if (null === $responseData) {
@ -471,7 +469,7 @@ class Protocol
*/
public function getPendingRequests(Uuid $sessionId): array
{
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$session = $this->sessionManager->createWithId($sessionId);
return $session->get(self::SESSION_PENDING_REQUESTS, []);
}
@ -498,7 +496,7 @@ class Protocol
return;
}
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$session = $this->sessionManager->createWithId($sessionId);
$payloadSessionId = $yieldedValue['session_id'] ?? null;
if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) {
@ -582,7 +580,7 @@ class Protocol
return null;
}
$session = $this->sessionFactory->create($this->sessionStore);
$session = $this->sessionManager->create();
$this->logger->debug('Created new session for initialize', [
'session_id' => $session->getId()->toRfc4122(),
]);
@ -599,33 +597,14 @@ class Protocol
return null;
}
if (!$this->sessionStore->exists($sessionId)) {
if (!$this->sessionManager->exists($sessionId)) {
$error = Error::forInvalidRequest('Session not found or has expired.');
$this->sendResponse($transport, $error, null, ['status_code' => 404]);
return null;
}
return $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
}
/**
* Run garbage collection on expired sessions.
* Uses the session store's internal TTL configuration.
*/
private function gcSessions(): void
{
if (random_int(0, 100) > 1) {
return;
}
$deletedSessions = $this->sessionStore->gc();
if (!empty($deletedSessions)) {
$this->logger->debug('Garbage collected expired sessions.', [
'count' => \count($deletedSessions),
'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions),
]);
}
return $this->sessionManager->createWithId($sessionId);
}
/**
@ -633,7 +612,7 @@ class Protocol
*/
public function destroySession(Uuid $sessionId): void
{
$this->sessionStore->destroy($sessionId);
$this->sessionManager->destroy($sessionId);
$this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]);
}
}

View file

@ -44,11 +44,6 @@ class Session implements SessionInterface
return $this->id;
}
public function getStore(): SessionStoreInterface
{
return $this->store;
}
public function save(): bool
{
return $this->store->write($this->id, json_encode($this->readData(), \JSON_THROW_ON_ERROR));

View file

@ -1,32 +0,0 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server\Session;
use Symfony\Component\Uid\Uuid;
/**
* Default implementation of SessionFactoryInterface.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SessionFactory implements SessionFactoryInterface
{
public function create(SessionStoreInterface $store): SessionInterface
{
return new Session($store, Uuid::v4());
}
public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface
{
return new Session($store, $id);
}
}

View file

@ -77,9 +77,4 @@ interface SessionInterface extends \JsonSerializable
* @param array<string, mixed> $attributes
*/
public function hydrate(array $attributes): void;
/**
* Get the session store instance.
*/
public function getStore(): SessionStoreInterface;
}

View file

@ -0,0 +1,69 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server\Session;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
/**
* Default implementation of SessionManagerInterface.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SessionManager implements SessionManagerInterface
{
public function __construct(
private readonly SessionStoreInterface $store,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function create(): SessionInterface
{
return new Session($this->store, Uuid::v4());
}
public function createWithId(Uuid $id): SessionInterface
{
return new Session($this->store, $id);
}
public function exists(Uuid $id): bool
{
return $this->store->exists($id);
}
public function destroy(Uuid $id): bool
{
return $this->store->destroy($id);
}
/**
* Run garbage collection on expired sessions.
* Uses the session store's internal TTL configuration.
*/
public function gc(): void
{
if (random_int(0, 100) > 1) {
return;
}
$deletedSessions = $this->store->gc();
if (!empty($deletedSessions)) {
$this->logger->debug('Garbage collected expired sessions.', [
'count' => \count($deletedSessions),
'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions),
]);
}
}
}

View file

@ -0,0 +1,47 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server\Session;
use Symfony\Component\Uid\Uuid;
/**
* Factory interface for creating session instances.
* This allows for different session implementations and custom initialization logic.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
interface SessionManagerInterface
{
/**
* Creates a new session with an auto-generated UUID.
* This is the standard factory method for creating sessions.
*/
public function create(): SessionInterface;
/**
* Creates a session with a specific UUID.
* Use this when you need to reconstruct a session with a known ID.
*/
public function createWithId(Uuid $id): SessionInterface;
/**
* Checks if a session with the given UUID exists.
*/
public function exists(Uuid $id): bool;
/**
* Destroys the session with the given UUID.
*/
public function destroy(Uuid $id): bool;
public function gc(): void;
}