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
49 changed files with 1614 additions and 406 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
composer.lock
vendor
examples/**/dev.log
examples/**/sessions

View file

@ -22,8 +22,11 @@
"ext-fileinfo": "*",
"opis/json-schema": "^2.4",
"phpdocumentor/reflection-docblock": "^5.6",
"psr/clock": "^1.0",
"psr/container": "^2.0",
"psr/event-dispatcher": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/finder": "^6.4 || ^7.3",
"symfony/uid": "^6.4 || ^7.3"
@ -34,7 +37,10 @@
"phpunit/phpunit": "^10.5",
"psr/cache": "^3.0",
"symfony/console": "^6.4 || ^7.3",
"symfony/process": "^6.4 || ^7.3"
"symfony/process": "^6.4 || ^7.3",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"laminas/laminas-httphandlerrunner": "^2.12"
},
"autoload": {
"psr-4": {
@ -51,10 +57,11 @@
"Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/",
"Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/",
"Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/",
"Mcp\\Example\\HttpTransportExample\\": "examples/10-simple-http-transport/",
"Mcp\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true
}
}
}

View file

@ -18,12 +18,17 @@ use Mcp\Server\Transport\StdioTransport;
logger()->info('Starting MCP Stdio Calculator Server...');
Server::make()
$server = Server::make()
->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.')
->setContainer(container())
->setLogger(logger())
->setDiscovery(__DIR__, ['.'])
->build()
->connect(new StdioTransport(logger: logger()));
->build();
$transport = new StdioTransport(logger: logger());
$server->connect($transport);
$transport->listen();
logger()->info('Server listener stopped gracefully.');

View file

@ -13,20 +13,23 @@
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Mcp\Capability\Registry\Container;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Psr\Log\LoggerInterface;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
logger()->info('Starting MCP HTTP User Profile Server...');
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
// --- Setup DI Container for DI in McpElements class ---
$container = new Container();
$container->set(LoggerInterface::class, logger());
$request = $creator->fromGlobals();
Server::make()
$server = Server::make()
->setServerInfo('HTTP User Profiles', '1.0.0')
->setLogger(logger())
->setContainer($container)
->setContainer(container())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__, ['.'])
->addTool(
function (float $a, float $b, string $operation = 'add'): array {
@ -70,7 +73,12 @@ Server::make()
description: 'Current system status and runtime information',
mimeType: 'application/json'
)
->build()
->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'));
->build();
logger()->info('Server listener stopped gracefully.');
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response);

View file

@ -19,7 +19,7 @@ use Mcp\Server\Transport\StdioTransport;
logger()->info('Starting MCP Manual Registration (Stdio) Server...');
Server::make()
$server = Server::make()
->setServerInfo('Manual Reg Server', '1.0.0')
->setLogger(logger())
->setContainer(container())
@ -27,7 +27,12 @@ Server::make()
->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain')
->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting')
->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json')
->build()
->connect(new StdioTransport(logger: logger()));
->build();
$transport = new StdioTransport(logger: logger());
$server->connect($transport);
$transport->listen();
logger()->info('Server listener stopped gracefully.');

View file

@ -13,16 +13,24 @@
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\CombinedHttpExample\Manual\ManualHandlers;
use Mcp\Server;
use Mcp\Server\Transports\HttpServerTransport;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
logger()->info('Starting MCP Combined Registration (HTTP) Server...');
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
Server::make()
$request = $creator->fromGlobals();
$server = Server::make()
->setServerInfo('Combined HTTP Server', '1.0.0')
->setLogger(logger())
->setContainer(container())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__, ['.'])
->addTool([ManualHandlers::class, 'manualGreeter'])
->addResource(
@ -30,7 +38,12 @@ Server::make()
'config://priority',
'priority_config_manual',
)
->build()
->connect(new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined'));
->build();
logger()->info('Server listener stopped gracefully.');
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response);

View file

@ -49,11 +49,16 @@ use Mcp\Server\Transport\StdioTransport;
logger()->info('Starting MCP Stdio Environment Variable Example Server...');
Server::make()
$server = Server::make()
->setServerInfo('Env Var Server', '1.0.0')
->setLogger(logger())
->setDiscovery(__DIR__, ['.'])
->build()
->connect(new StdioTransport(logger: logger()));
->build();
$transport = new StdioTransport(logger: logger());
$server->connect($transport);
$transport->listen();
logger()->info('Server listener stopped gracefully.');

View file

@ -27,12 +27,17 @@ $container->set(Services\TaskRepositoryInterface::class, $taskRepo);
$statsService = new Services\SystemStatsService($taskRepo);
$container->set(Services\StatsServiceInterface::class, $statsService);
Server::make()
$server = Server::make()
->setServerInfo('Task Manager Server', '1.0.0')
->setLogger(logger())
->setContainer($container)
->setDiscovery(__DIR__, ['.'])
->build()
->connect(new StdioTransport(logger: logger()));
->build();
$transport = new StdioTransport(logger: logger());
$server->connect($transport);
$transport->listen();
logger()->info('Server listener stopped gracefully.');

View file

@ -13,17 +13,30 @@
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Transports\HttpServerTransport;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
logger()->info('Starting MCP Complex Schema HTTP Server...');
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
Server::make()
$request = $creator->fromGlobals();
$server = Server::make()
->setServerInfo('Event Scheduler Server', '1.0.0')
->setLogger(logger())
->setContainer(container())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__, ['.'])
->build()
->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler'));
->build();
logger()->info('Server listener stopped gracefully.');
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response);

View file

@ -13,16 +13,29 @@
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Transports\StreamableHttpServerTransport;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
logger()->info('Starting MCP Schema Showcase Server...');
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
Server::make()
$request = $creator->fromGlobals();
$server = Server::make()
->setServerInfo('Schema Showcase', '1.0.0')
->setLogger(logger())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__, ['.'])
->build()
->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'));
->build();
logger()->info('Server listener stopped gracefully.');
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response);

View file

@ -11,6 +11,8 @@
require __DIR__.'/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
use Symfony\Component\Console as SymfonyConsole;
use Symfony\Component\Console\Output\OutputInterface;
@ -20,18 +22,14 @@ $debug = (bool) ($_SERVER['DEBUG'] ?? false);
$output = new SymfonyConsole\Output\ConsoleOutput($debug ? OutputInterface::VERBOSITY_VERY_VERBOSE : OutputInterface::VERBOSITY_NORMAL);
$logger = new SymfonyConsole\Logger\ConsoleLogger($output);
// Configure the JsonRpcHandler and build the functionality
$jsonRpcHandler = new Mcp\JsonRpc\Handler(
Mcp\JsonRpc\MessageFactory::make(),
App\Builder::buildMethodHandlers(),
$logger
);
$server = Server::make()
->setServerInfo('Standalone CLI', '1.0.0')
->setLogger($logger)
->setDiscovery(__DIR__, ['.'])
->build();
// Set up the server
$sever = new Mcp\Server($jsonRpcHandler, $logger);
$transport = new StdioTransport(logger: $logger);
// Create the transport layer using Stdio
$transport = new Mcp\Server\Transport\StdioTransport(logger: $logger);
$server->connect($transport);
// Start our application
$sever->connect($transport);
$transport->listen();

View file

@ -0,0 +1,118 @@
<?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\Example\HttpTransportExample;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
class McpElements
{
public function __construct()
{
}
/**
* Get the current server time.
*/
#[McpTool(name: 'current_time')]
public function getCurrentTime(string $format = 'Y-m-d H:i:s'): string
{
try {
return date($format);
} catch (\Exception $e) {
return date('Y-m-d H:i:s'); // fallback
}
}
/**
* Calculate simple math operations.
*/
#[McpTool(name: 'calculate')]
public function calculate(float $a, float $b, string $operation): float|string
{
return match (strtolower($operation)) {
'add', '+' => $a + $b,
'subtract', '-' => $a - $b,
'multiply', '*' => $a * $b,
'divide', '/' => 0 != $b ? $a / $b : 'Error: Division by zero',
default => 'Error: Unknown operation. Use: add, subtract, multiply, divide',
};
}
/**
* Server information resource.
*
* @return array{status: string, timestamp: int, version: string, transport: string, uptime: int}
*/
#[McpResource(
uri: 'info://server/status',
name: 'server_status',
description: 'Current server status and information',
mimeType: 'application/json'
)]
public function getServerStatus(): array
{
return [
'status' => 'running',
'timestamp' => time(),
'version' => '1.0.0',
'transport' => 'HTTP',
'uptime' => time() - $_SERVER['REQUEST_TIME'],
];
}
/**
* Configuration resource.
*
* @return array{debug: bool, environment: string, timezone: string, locale: string}
*/
#[McpResource(
uri: 'config://app/settings',
name: 'app_config',
description: 'Application configuration settings',
mimeType: 'application/json'
)]
public function getAppConfig(): array
{
return [
'debug' => $_SERVER['DEBUG'] ?? false,
'environment' => $_SERVER['APP_ENV'] ?? 'production',
'timezone' => date_default_timezone_get(),
'locale' => 'en_US',
];
}
/**
* Greeting prompt.
*
* @return array{role: string, content: string}
*/
#[McpPrompt(
name: 'greet',
description: 'Generate a personalized greeting message'
)]
public function greetPrompt(string $firstName = 'World', string $timeOfDay = 'day'): array
{
$greeting = match (strtolower($timeOfDay)) {
'morning' => 'Good morning',
'afternoon' => 'Good afternoon',
'evening', 'night' => 'Good evening',
default => 'Hello',
};
return [
'role' => 'user',
'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport.",
];
}
}

View file

@ -0,0 +1,24 @@
# HTTP MCP Server Example
This example demonstrates how to use the MCP SDK with HTTP transport using the StreamableHttpTransport. It provides a complete HTTP-based MCP server that can handle JSON-RPC requests over HTTP POST.
## Usage
**Step 1: Start the HTTP server**
```bash
cd examples/10-simple-http-transport
php -S localhost:8000 server.php
```
**Step 2: Connect with MCP Inspector**
```bash
npx @modelcontextprotocol/inspector http://localhost:8000
```
## Available Features
- **Tools**: `current_time`, `calculate`
- **Resources**: `info://server/status`, `config://app/settings`
- **Prompts**: `greet`

View file

@ -0,0 +1,40 @@
<?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.
*/
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
$request = $creator->fromGlobals();
$server = Server::make()
->setServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport')
->setContainer(container())
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setDiscovery(__DIR__, ['.'])
->build();
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response);

View file

@ -54,17 +54,6 @@ parameters:
count: 1
path: examples/02-discovery-http-userprofile/server.php
-
message: '#^Instantiated class StreamableHttpServerTransport not found\.$#'
identifier: class.notFound
count: 1
path: examples/02-discovery-http-userprofile/server.php
-
message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, StreamableHttpServerTransport given\.$#'
identifier: argument.type
count: 1
path: examples/02-discovery-http-userprofile/server.php
-
message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#'
@ -84,17 +73,6 @@ parameters:
count: 2
path: examples/04-combined-registration-http/server.php
-
message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#'
identifier: class.notFound
count: 1
path: examples/04-combined-registration-http/server.php
-
message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#'
identifier: argument.type
count: 1
path: examples/04-combined-registration-http/server.php
-
message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#'
@ -288,17 +266,6 @@ parameters:
count: 2
path: examples/07-complex-tool-schema-http/McpEventScheduler.php
-
message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#'
identifier: class.notFound
count: 1
path: examples/07-complex-tool-schema-http/server.php
-
message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#'
identifier: argument.type
count: 1
path: examples/07-complex-tool-schema-http/server.php
-
message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#'
@ -354,17 +321,6 @@ parameters:
count: 1
path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php
-
message: '#^Instantiated class Mcp\\Server\\Transports\\StreamableHttpServerTransport not found\.$#'
identifier: class.notFound
count: 1
path: examples/08-schema-showcase-streamable/server.php
-
message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\StreamableHttpServerTransport given\.$#'
identifier: argument.type
count: 1
path: examples/08-schema-showcase-streamable/server.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#'

View file

@ -24,11 +24,16 @@ use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\InitializeRequest;
use Mcp\Server\MethodHandlerInterface;
use Mcp\Server\NotificationHandler;
use Mcp\Server\RequestHandler;
use Mcp\Server\Session\SessionFactoryInterface;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Session\SessionStoreInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
/**
* @final
@ -47,6 +52,8 @@ class Handler
*/
public function __construct(
private readonly MessageFactory $messageFactory,
private readonly SessionFactoryInterface $sessionFactory,
private readonly SessionStoreInterface $sessionStore,
iterable $methodHandlers,
private readonly LoggerInterface $logger = new NullLogger(),
) {
@ -62,10 +69,14 @@ class Handler
ToolCallerInterface $toolCaller,
ResourceReaderInterface $resourceReader,
PromptGetterInterface $promptGetter,
SessionStoreInterface $sessionStore,
SessionFactoryInterface $sessionFactory,
LoggerInterface $logger = new NullLogger(),
): self {
return new self(
messageFactory: MessageFactory::make(),
sessionFactory: $sessionFactory,
sessionStore: $sessionStore,
methodHandlers: [
new NotificationHandler\InitializedHandler(),
new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation),
@ -82,29 +93,78 @@ class Handler
}
/**
* @return iterable<string|null>
* @return iterable<array{string|null, array<string, mixed>}>
*
* @throws ExceptionInterface When a handler throws an exception during message processing
* @throws \JsonException When JSON encoding of the response fails
*/
public function process(string $input): iterable
public function process(string $input, ?Uuid $sessionId): iterable
{
$this->logger->info('Received message to process.', ['message' => $input]);
$this->runGarbageCollection();
try {
$messages = $this->messageFactory->create($input);
$messages = iterator_to_array($this->messageFactory->create($input));
} catch (\JsonException $e) {
$this->logger->warning('Failed to decode json message.', ['exception' => $e]);
yield $this->encodeResponse(Error::forParseError($e->getMessage()));
$error = Error::forParseError($e->getMessage());
yield [$this->encodeResponse($error), []];
return;
}
$hasInitializeRequest = false;
foreach ($messages as $message) {
if ($message instanceof InitializeRequest) {
$hasInitializeRequest = true;
break;
}
}
$session = null;
if ($hasInitializeRequest) {
// Spec: An initialize request must not be part of a batch.
if (\count($messages) > 1) {
$error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.');
yield [$this->encodeResponse($error), []];
return;
}
// Spec: An initialize request must not have a session ID.
if ($sessionId) {
$error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.');
yield [$this->encodeResponse($error), []];
return;
}
$session = $this->sessionFactory->create($this->sessionStore);
} else {
if (!$sessionId) {
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.');
yield [$this->encodeResponse($error), ['status_code' => 400]];
return;
}
if (!$this->sessionStore->exists($sessionId)) {
$error = Error::forInvalidRequest('Session not found or has expired.');
yield [$this->encodeResponse($error), ['status_code' => 404]];
return;
}
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
}
foreach ($messages as $message) {
if ($message instanceof InvalidInputMessageException) {
$this->logger->warning('Failed to create message.', ['exception' => $message]);
yield $this->encodeResponse(Error::forInvalidRequest($message->getMessage(), 0));
$error = Error::forInvalidRequest($message->getMessage(), 0);
yield [$this->encodeResponse($error), []];
continue;
}
@ -113,24 +173,32 @@ class Handler
]);
try {
yield $this->encodeResponse($this->handle($message));
$response = $this->handle($message, $session);
yield [$this->encodeResponse($response), ['session_id' => $session->getId()]];
} catch (\DomainException) {
yield null;
yield [null, []];
} catch (NotFoundExceptionInterface $e) {
$this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e],
$this->logger->warning(
\sprintf('Failed to create response: %s', $e->getMessage()),
['exception' => $e],
);
yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage()));
$error = Error::forMethodNotFound($e->getMessage());
yield [$this->encodeResponse($error), []];
} catch (\InvalidArgumentException $e) {
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
yield $this->encodeResponse(Error::forInvalidParams($e->getMessage()));
$error = Error::forInvalidParams($e->getMessage());
yield [$this->encodeResponse($error), []];
} catch (\Throwable $e) {
$this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);
yield $this->encodeResponse(Error::forInternalError($e->getMessage()));
$error = Error::forInternalError($e->getMessage());
yield [$this->encodeResponse($error), []];
}
}
$session->save();
}
/**
@ -159,7 +227,7 @@ class Handler
* @throws NotFoundExceptionInterface When no handler is found for the request method
* @throws ExceptionInterface When a request handler throws an exception
*/
private function handle(HasMethodInterface $message): Response|Error|null
private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null
{
$this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [
'message' => $message,
@ -167,18 +235,20 @@ class Handler
$handled = false;
foreach ($this->methodHandlers as $handler) {
if ($handler->supports($message)) {
$return = $handler->handle($message);
$handled = true;
if (!$handler->supports($message)) {
continue;
}
$this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [
'method' => $message::getMethod(),
'response' => $return,
]);
$return = $handler->handle($message, $session);
$handled = true;
if (null !== $return) {
return $return;
}
$this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [
'method' => $message::getMethod(),
'response' => $return,
]);
if (null !== $return) {
return $return;
}
}
@ -188,4 +258,32 @@ class Handler
throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod()));
}
/**
* Run garbage collection on expired sessions.
* Uses the session store's internal TTL configuration.
*/
private function runGarbageCollection(): 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(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions),
]);
}
}
/**
* Destroy a specific session.
*/
public function destroySession(Uuid $sessionId): void
{
$this->sessionStore->destroy($sessionId);
$this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]);
}
}

View file

@ -16,9 +16,11 @@ use Mcp\Server\ServerBuilder;
use Mcp\Server\TransportInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class Server
{
@ -36,37 +38,30 @@ final class Server
public function connect(TransportInterface $transport): void
{
$transport->initialize();
$this->logger->info('Transport initialized.', [
'transport' => $transport::class,
]);
while ($transport->isConnected()) {
foreach ($transport->receive() as $message) {
if (null === $message) {
continue;
}
try {
foreach ($this->jsonRpcHandler->process($message) as $response) {
if (null === $response) {
continue;
}
$transport->send($response);
$transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) {
try {
foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) {
if (null === $response) {
continue;
}
} catch (\JsonException $e) {
$this->logger->error('Failed to encode response to JSON.', [
'message' => $message,
'exception' => $e,
]);
continue;
$transport->send($response, $context);
}
} catch (\JsonException $e) {
$this->logger->error('Failed to encode response to JSON.', [
'message' => $message,
'exception' => $e,
]);
}
});
usleep(1000);
}
$transport->close();
$this->logger->info('Transport closed');
$transport->onSessionEnd(function (Uuid $sessionId) {
$this->jsonRpcHandler->destroySession($sessionId);
});
}
}

View file

@ -16,6 +16,7 @@ use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Server\Session\SessionInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
@ -27,5 +28,5 @@ interface MethodHandlerInterface
/**
* @throws ExceptionInterface When the handler encounters an error processing the request
*/
public function handle(HasMethodInterface $message): Response|Error|null;
public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null;
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* 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;
use Psr\Clock\ClockInterface;
class NativeClock implements ClockInterface
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}

View file

@ -16,6 +16,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Notification\InitializedNotification;
use Mcp\Server\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
@ -27,8 +28,10 @@ final class InitializedHandler implements MethodHandlerInterface
return $message instanceof InitializedNotification;
}
public function handle(InitializedNotification|HasMethodInterface $message): Response|Error|null
public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null
{
$session->set('initialized', true);
return null;
}
}

Some files were not shown because too many files have changed in this diff Show more