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:
parent
b1e54f1f86
commit
719262b15d
49 changed files with 1614 additions and 406 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
|||
composer.lock
|
||||
vendor
|
||||
examples/**/dev.log
|
||||
examples/**/sessions
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
118
examples/10-simple-http-transport/McpElements.php
Normal file
118
examples/10-simple-http-transport/McpElements.php
Normal 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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
24
examples/10-simple-http-transport/README.md
Normal file
24
examples/10-simple-http-transport/README.md
Normal 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`
|
||||
40
examples/10-simple-http-transport/server.php
Normal file
40
examples/10-simple-http-transport/server.php
Normal 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);
|
||||
|
|
@ -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\.$#'
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
24
src/Server/NativeClock.php
Normal file
24
src/Server/NativeClock.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue