feat(server): Rework transport architecture and add StreamableHttpTransport (#49)

* feat(server): Rework transport architecture and add StreamableHttpTransport

- `Server::connect()` no longer contains a processing loop.
- `TransportInterface` is updated with `setMessageHandler()` and `listen()`.
- `StdioTransport` is updated to implement the new interface.
- A new, minimal `StreamableHttpTransport` is added for stateless HTTP.

* refactor(server): use  event-driven message handling

* feat(server): Introduce a formal session management system

* fix: ensure session elements are preserved when building server (regression from #46)

* refactor: consolidate HTTP example to use shared dependencies

* feat(server): Enhance message handling with session support

- Updated `TransportInterface` to use `onMessage` for handling incoming messages with session IDs.
- Refactored `Server`, `Handler`, and transport classes to accommodate session management using `Uuid`.
- Introduced methods for creating sessions with auto-generated and specific UUIDs in `SessionFactory` and `SessionFactoryInterface`.

* feat(server): Integrate session management in message handling

- Added session support to the `Server` and `Handler` classes, allowing for session data to be managed during message processing.
- Updated `TransportInterface` to include session context in the `send` method.
- Refactored various request handlers to utilize session information, ensuring proper session handling for incoming requests.
- Introduced a file-based session store for persistent session data management

* feat: Update session builder method to use set* instead of with*

* feat: Fix test compatibility with new session management architecture

* chore: apply code style fixes

* fix: remove deprecated SSE transport and resolve PHPStan issues
This commit is contained in:
Kyrian Obikwelu 2025-09-21 11:12:40 +01:00 • committed by GitHub
commit 719262b15d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1614 additions and 406 deletions

View file

@ -12,13 +12,20 @@
namespace Mcp\Server\Transport;
use Mcp\Server\TransportInterface;
use Symfony\Component\Uid\Uuid;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class InMemoryTransport implements TransportInterface
{
private bool $connected = true;
/** @var callable(string, ?Uuid): void */
private $messageListener;
/** @var callable(Uuid): void */
private $sessionDestroyListener;
private ?Uuid $sessionId = null;
/**
* @param list<string> $messages
@ -32,22 +39,42 @@ class InMemoryTransport implements TransportInterface
{
}
public function isConnected(): bool
public function onMessage(callable $listener): void
{
return $this->connected;
$this->messageListener = $listener;
}
public function receive(): \Generator
public function send(string $data, array $context): void
{
yield from $this->messages;
$this->connected = false;
if (isset($context['session_id'])) {
$this->sessionId = $context['session_id'];
}
}
public function send(string $data): void
public function listen(): mixed
{
foreach ($this->messages as $message) {
if (\is_callable($this->messageListener)) {
\call_user_func($this->messageListener, $message, $this->sessionId);
}
}
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
\call_user_func($this->sessionDestroyListener, $this->sessionId);
}
return null;
}
public function onSessionEnd(callable $listener): void
{
$this->sessionDestroyListener = $listener;
}
public function close(): void
{
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
\call_user_func($this->sessionDestroyListener, $this->sessionId);
}
}
}

View file

@ -1,65 +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\Transport\Sse\Store;
use Mcp\Server\Transport\Sse\StoreInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Uid\Uuid;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class CachePoolStore implements StoreInterface
{
public function __construct(
private readonly CacheItemPoolInterface $cachePool,
) {
}
public function push(Uuid $id, string $message): void
{
$item = $this->cachePool->getItem($this->getCacheKey($id));
$messages = $item->isHit() ? $item->get() : [];
$messages[] = $message;
$item->set($messages);
$this->cachePool->save($item);
}
public function pop(Uuid $id): ?string
{
$item = $this->cachePool->getItem($this->getCacheKey($id));
if (!$item->isHit()) {
return null;
}
$messages = $item->get();
$message = array_shift($messages);
$item->set($messages);
$this->cachePool->save($item);
return $message;
}
public function remove(Uuid $id): void
{
$this->cachePool->deleteItem($this->getCacheKey($id));
}
private function getCacheKey(Uuid $id): string
{
return 'message_'.$id->toRfc4122();
}
}

View file

@ -1,26 +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\Transport\Sse;
use Symfony\Component\Uid\Uuid;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface StoreInterface
{
public function push(Uuid $id, string $message): void;
public function pop(Uuid $id): ?string;
public function remove(Uuid $id): void;
}

View file

@ -1,65 +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\Transport\Sse;
use Mcp\Server\TransportInterface;
use Symfony\Component\Uid\Uuid;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class StreamTransport implements TransportInterface
{
public function __construct(
private readonly string $messageEndpoint,
private readonly StoreInterface $store,
private readonly Uuid $id,
) {
}
public function initialize(): void
{
ignore_user_abort(true);
$this->flushEvent('endpoint', $this->messageEndpoint);
}
public function isConnected(): bool
{
return 0 === connection_aborted();
}
public function receive(): \Generator
{
yield $this->store->pop($this->id);
}
public function send(string $data): void
{
$this->flushEvent('message', $data);
}
public function close(): void
{
$this->store->remove($this->id);
}
private function flushEvent(string $event, string $data): void
{
echo \sprintf('event: %s', $event).\PHP_EOL;
echo \sprintf('data: %s', $data).\PHP_EOL;
echo \PHP_EOL;
if (false !== ob_get_length()) {
ob_flush();
}
flush();
}
}

View file

@ -14,13 +14,20 @@ namespace Mcp\Server\Transport;
use Mcp\Server\TransportInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
/**
* Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents.
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class StdioTransport implements TransportInterface
{
private string $buffer = '';
/** @var callable(string, ?Uuid): void */
private $messageListener;
/** @var callable(Uuid): void */
private $sessionEndListener;
private ?Uuid $sessionId = null;
/**
* @param resource $input
@ -37,39 +44,67 @@ class StdioTransport implements TransportInterface
{
}
public function isConnected(): bool
public function onMessage(callable $listener): void
{
return true;
$this->messageListener = $listener;
}
public function receive(): \Generator
{
$line = fgets($this->input);
$this->logger->debug('Received message on StdioTransport.', [
'line' => $line,
]);
if (false === $line) {
return;
}
$this->buffer .= rtrim($line).\PHP_EOL;
if (str_contains($this->buffer, \PHP_EOL)) {
$lines = explode(\PHP_EOL, $this->buffer);
$this->buffer = array_pop($lines);
yield from $lines;
}
}
public function send(string $data): void
public function send(string $data, array $context): void
{
$this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]);
if (isset($context['session_id'])) {
$this->sessionId = $context['session_id'];
}
fwrite($this->output, $data.\PHP_EOL);
}
public function listen(): mixed
{
$this->logger->info('StdioTransport is listening for messages on STDIN...');
while (!feof($this->input)) {
$line = fgets($this->input);
if (false === $line) {
break;
}
$trimmedLine = trim($line);
if (!empty($trimmedLine)) {
$this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]);
if (\is_callable($this->messageListener)) {
\call_user_func($this->messageListener, $trimmedLine, $this->sessionId);
}
}
}
$this->logger->info('StdioTransport finished listening.');
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
\call_user_func($this->sessionEndListener, $this->sessionId);
}
return null;
}
public function onSessionEnd(callable $listener): void
{
$this->sessionEndListener = $listener;
}
public function close(): void
{
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
\call_user_func($this->sessionEndListener, $this->sessionId);
}
if (\is_resource($this->input)) {
fclose($this->input);
}
if (\is_resource($this->output)) {
fclose($this->output);
}
}
}

View file

@ -0,0 +1,218 @@
<?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\Transport;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server\TransportInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
/**
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class StreamableHttpTransport implements TransportInterface
{
/** @var callable(string, ?Uuid): void */
private $messageListener;
/** @var callable(Uuid): void */
private $sessionEndListener;
private ?Uuid $sessionId = null;
/** @var string[] */
private array $outgoingMessages = [];
private ?Uuid $outgoingSessionId = null;
private ?int $outgoingStatusCode = null;
/** @var array<string, string> */
private array $corsHeaders = [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept',
];
public function __construct(
private readonly ServerRequestInterface $request,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly LoggerInterface $logger = new NullLogger(),
) {
$sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id');
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
}
public function initialize(): void
{
}
public function send(string $data, array $context): void
{
$this->outgoingMessages[] = $data;
if (isset($context['session_id'])) {
$this->outgoingSessionId = $context['session_id'];
}
if (isset($context['status_code']) && \is_int($context['status_code'])) {
$this->outgoingStatusCode = $context['status_code'];
}
$this->logger->debug('Sending data to client via StreamableHttpTransport.', [
'data' => $data,
'session_id' => $this->outgoingSessionId?->toRfc4122(),
'status_code' => $this->outgoingStatusCode,
]);
}
public function listen(): mixed
{
return match ($this->request->getMethod()) {
'OPTIONS' => $this->handleOptionsRequest(),
'GET' => $this->handleGetRequest(),
'POST' => $this->handlePostRequest(),
'DELETE' => $this->handleDeleteRequest(),
default => $this->handleUnsupportedRequest(),
};
}
public function onMessage(callable $listener): void
{
$this->messageListener = $listener;
}
public function onSessionEnd(callable $listener): void
{
$this->sessionEndListener = $listener;
}
protected function handleOptionsRequest(): ResponseInterface
{
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
}
protected function handlePostRequest(): ResponseInterface
{
$acceptHeader = $this->request->getHeaderLine('Accept');
if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) {
$error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.');
$this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]);
return $this->createErrorResponse($error, 406);
}
if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) {
$error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.');
$this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]);
return $this->createErrorResponse($error, 415);
}
$body = $this->request->getBody()->getContents();
if (empty($body)) {
$error = Error::forInvalidRequest('Bad Request: Empty request body.');
$this->logger->warning('Client sent empty request body.');
return $this->createErrorResponse($error, 400);
}
$this->logger->debug('Received message on StreamableHttpTransport.', [
'body' => $body,
'session_id' => $this->sessionId?->toRfc4122(),
]);
if (\is_callable($this->messageListener)) {
\call_user_func($this->messageListener, $body, $this->sessionId);
}
if (empty($this->outgoingMessages)) {
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
}
$responseBody = 1 === \count($this->outgoingMessages)
? $this->outgoingMessages[0]
: '['.implode(',', $this->outgoingMessages).']';
$status = $this->outgoingStatusCode ?? 200;
$response = $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($responseBody));
if ($this->outgoingSessionId) {
$response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122());
}
return $this->withCorsHeaders($response);
}
protected function handleGetRequest(): ResponseInterface
{
$response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405);
return $this->withCorsHeaders($response);
}
protected function handleDeleteRequest(): ResponseInterface
{
if (!$this->sessionId) {
$error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.');
$this->logger->warning('DELETE request received without session ID.');
return $this->createErrorResponse($error, 400);
}
if (\is_callable($this->sessionEndListener)) {
\call_user_func($this->sessionEndListener, $this->sessionId);
}
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
}
protected function handleUnsupportedRequest(): ResponseInterface
{
$this->logger->warning('Unsupported HTTP method received.', [
'method' => $this->request->getMethod(),
]);
$response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405);
return $this->withCorsHeaders($response);
}
protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
{
foreach ($this->corsHeaders as $name => $value) {
$response = $response->withHeader($name, $value);
}
return $response;
}
protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface
{
$errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR);
return $this->responseFactory->createResponse($statusCode)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($errorPayload));
}
public function close(): void
{
}
}