diff --git a/composer.json b/composer.json index 94e96de9..55892084 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", @@ -33,6 +34,9 @@ "symfony/uid": "^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", "php-cs-fixer/shim": "^3.91", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", @@ -40,10 +44,8 @@ "psr/simple-cache": "^3.0", "symfony/cache": "^6.4 || ^7.3 || ^8.0", "symfony/console": "^6.4 || ^7.3 || ^8.0", - "symfony/process": "^6.4 || ^7.3 || ^8.0", - "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", - "laminas/laminas-httphandlerrunner": "^2.12" + "symfony/http-client": "^7.4", + "symfony/process": "^6.4 || ^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -52,18 +54,18 @@ }, "autoload-dev": { "psr-4": { - "Mcp\\Example\\CachedDiscovery\\": "examples/cached-discovery/", - "Mcp\\Example\\ClientCommunication\\": "examples/client-communication/", - "Mcp\\Example\\CombinedRegistration\\": "examples/combined-registration/", - "Mcp\\Example\\ComplexToolSchema\\": "examples/complex-tool-schema/", - "Mcp\\Example\\CustomDependencies\\": "examples/custom-dependencies/", - "Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/", - "Mcp\\Example\\DiscoveryCalculator\\": "examples/discovery-calculator/", - "Mcp\\Example\\DiscoveryUserProfile\\": "examples/discovery-userprofile/", - "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", - "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", - "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", - "Mcp\\Example\\ClientLogging\\": "examples/client-logging/", + "Mcp\\Example\\Server\\CachedDiscovery\\": "examples/server/cached-discovery/", + "Mcp\\Example\\Server\\ClientCommunication\\": "examples/server/client-communication/", + "Mcp\\Example\\Server\\CombinedRegistration\\": "examples/server/combined-registration/", + "Mcp\\Example\\Server\\ComplexToolSchema\\": "examples/server/complex-tool-schema/", + "Mcp\\Example\\Server\\CustomDependencies\\": "examples/server/custom-dependencies/", + "Mcp\\Example\\Server\\CustomMethodHandlers\\": "examples/server/custom-method-handlers/", + "Mcp\\Example\\Server\\DiscoveryCalculator\\": "examples/server/discovery-calculator/", + "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", + "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", + "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", + "Mcp\\Example\\Server\\ClientLogging\\": "examples/server/client-logging/", "Mcp\\Tests\\": "tests/" } }, @@ -73,4 +75,4 @@ "php-http/discovery": false } } -} +} \ No newline at end of file diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 00000000..afdefb32 --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,27 @@ +# Client Examples + +These examples demonstrate how to use the MCP PHP Client SDK. + +## STDIO Client + +Connects to an MCP server running as a child process: + +```bash +php examples/client/stdio_discovery_calculator.php +``` + +## HTTP Client + +Connects to an MCP server over HTTP: + +```bash +# First, start an HTTP server +php -S localhost:8080 examples/http-discovery-userprofile/server.php + +# Then run the client +php examples/client/http_discovery_userprofile.php +``` + +## Requirements + +All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php new file mode 100644 index 00000000..c9894025 --- /dev/null +++ b/examples/client/http_client_communication.php @@ -0,0 +1,122 @@ +level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(function (CreateSamplingMessageRequest $request): CreateSamplingMessageResult { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . + "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); +}); + +$client = Client::builder() + ->setClientInfo('HTTP Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) + ->build(); + +$transport = new HttpClientTransport(endpoint: $endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'sales_transactions_q4'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php new file mode 100644 index 00000000..283c1cdc --- /dev/null +++ b/examples/client/http_discovery_calculator.php @@ -0,0 +1,69 @@ +setClientInfo('HTTP Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new HttpClientTransport($endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Available prompts:\n"; + $promptsResult = $client->listPrompts(); + foreach ($promptsResult->prompts as $prompt) { + echo " - {$prompt->name}: {$prompt->description}\n"; + } + echo "\n"; + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php new file mode 100644 index 00000000..ba6ed872 --- /dev/null +++ b/examples/client/stdio_client_communication.php @@ -0,0 +1,114 @@ +level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(function (CreateSamplingMessageRequest $request): CreateSamplingMessageResult { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . + "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); +}); + +$client = Client::builder() + ->setClientInfo('STDIO Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) + ->build(); + +$transport = new StdioClientTransport( + command: 'php', + args: [__DIR__ . '/../server/client-communication/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'customer_orders_2024'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php new file mode 100644 index 00000000..935575c0 --- /dev/null +++ b/examples/client/stdio_discovery_calculator.php @@ -0,0 +1,80 @@ +setClientInfo('STDIO Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new StdioClientTransport( + command: 'php', + args: [__DIR__ . '/../server/discovery-calculator/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Calling 'calculate' tool with a=5, b=3, operation='add'...\n"; + $result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); + echo "Result: "; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } + } + echo "\n\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Reading resource 'config://calculator/settings'...\n"; + $resourceContent = $client->readResource('config://calculator/settings'); + foreach ($resourceContent->contents as $content) { + if ($content instanceof TextResourceContents) { + echo " Content: " . $content->text . "\n"; + echo " Mimetype: " . $content->mimeType . "\n"; + } + } +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/README.md b/examples/server/README.md similarity index 78% rename from examples/README.md rename to examples/server/README.md index 27874d71..a9326395 100644 --- a/examples/README.md +++ b/examples/server/README.md @@ -8,10 +8,10 @@ The bootstrapping of the example will choose the used transport based on the SAP For running an example, you execute the `server.php` like this: ```bash # For using the STDIO transport: -php examples/discovery-calculator/server.php +php examples/server/discovery-calculator/server.php # For using the Streamable HTTP transport: -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -19,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` ## Debugging @@ -30,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php ``` diff --git a/examples/bootstrap.php b/examples/server/bootstrap.php similarity index 93% rename from examples/bootstrap.php rename to examples/server/bootstrap.php index 8c0508ab..3602485d 100644 --- a/examples/bootstrap.php +++ b/examples/server/bootstrap.php @@ -19,7 +19,7 @@ use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; -require_once dirname(__DIR__).'/vendor/autoload.php'; +require_once dirname(__DIR__, 2).'/vendor/autoload.php'; set_exception_handler(function (Throwable $t): never { logger()->critical('Uncaught exception: '.$t->getMessage(), ['exception' => $t]); @@ -55,7 +55,7 @@ function shutdown(ResponseInterface|int $result): never function logger(): LoggerInterface { return new class extends AbstractLogger { - public function log($level, Stringable|string $message, array $context = []): void + public function log($level, $message, array $context = []): void { $debug = $_SERVER['DEBUG'] ?? false; diff --git a/examples/cached-discovery/CachedCalculatorElements.php b/examples/server/cached-discovery/CachedCalculatorElements.php similarity index 96% rename from examples/cached-discovery/CachedCalculatorElements.php rename to examples/server/cached-discovery/CachedCalculatorElements.php index ef67b0ff..9e9766b3 100644 --- a/examples/cached-discovery/CachedCalculatorElements.php +++ b/examples/server/cached-discovery/CachedCalculatorElements.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CachedDiscovery; +namespace Mcp\Example\Server\CachedDiscovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; diff --git a/examples/cached-discovery/server.php b/examples/server/cached-discovery/server.php similarity index 100% rename from examples/cached-discovery/server.php rename to examples/server/cached-discovery/server.php diff --git a/examples/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php similarity index 97% rename from examples/client-communication/ClientAwareService.php rename to examples/server/client-communication/ClientAwareService.php index 70b77bdd..b33f4a75 100644 --- a/examples/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ClientCommunication; +namespace Mcp\Example\Server\ClientCommunication; use Mcp\Capability\Attribute\McpTool; use Mcp\Schema\Content\TextContent; diff --git a/examples/client-communication/server.php b/examples/server/client-communication/server.php similarity index 93% rename from examples/client-communication/server.php rename to examples/server/client-communication/server.php index 42e5058b..29d45eb4 100644 --- a/examples/client-communication/server.php +++ b/examples/server/client-communication/server.php @@ -1,4 +1,3 @@ -#!/usr/bin/env php setServerInfo('Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) - ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) ->setDiscovery(__DIR__) ->addTool( diff --git a/examples/client-logging/LoggingShowcaseHandlers.php b/examples/server/client-logging/LoggingShowcaseHandlers.php similarity index 98% rename from examples/client-logging/LoggingShowcaseHandlers.php rename to examples/server/client-logging/LoggingShowcaseHandlers.php index 35dfa232..422efaf1 100644 --- a/examples/client-logging/LoggingShowcaseHandlers.php +++ b/examples/server/client-logging/LoggingShowcaseHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ClientLogging; +namespace Mcp\Example\Server\ClientLogging; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Logger\ClientLogger; diff --git a/examples/client-logging/server.php b/examples/server/client-logging/server.php similarity index 100% rename from examples/client-logging/server.php rename to examples/server/client-logging/server.php diff --git a/examples/combined-registration/DiscoveredElements.php b/examples/server/combined-registration/DiscoveredElements.php similarity index 95% rename from examples/combined-registration/DiscoveredElements.php rename to examples/server/combined-registration/DiscoveredElements.php index f7142466..c2f93ac3 100644 --- a/examples/combined-registration/DiscoveredElements.php +++ b/examples/server/combined-registration/DiscoveredElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedRegistration; +namespace Mcp\Example\Server\CombinedRegistration; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/combined-registration/ManualHandlers.php b/examples/server/combined-registration/ManualHandlers.php similarity index 95% rename from examples/combined-registration/ManualHandlers.php rename to examples/server/combined-registration/ManualHandlers.php index 65a86bc6..14885ef4 100644 --- a/examples/combined-registration/ManualHandlers.php +++ b/examples/server/combined-registration/ManualHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedRegistration; +namespace Mcp\Example\Server\CombinedRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/combined-registration/server.php b/examples/server/combined-registration/server.php similarity index 93% rename from examples/combined-registration/server.php rename to examples/server/combined-registration/server.php index 02f26a4e..cf61d55a 100644 --- a/examples/combined-registration/server.php +++ b/examples/server/combined-registration/server.php @@ -13,7 +13,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\CombinedRegistration\ManualHandlers; +use Mcp\Example\Server\CombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; diff --git a/examples/complex-tool-schema/McpEventScheduler.php b/examples/server/complex-tool-schema/McpEventScheduler.php similarity index 93% rename from examples/complex-tool-schema/McpEventScheduler.php rename to examples/server/complex-tool-schema/McpEventScheduler.php index 253ff5cb..366c687e 100644 --- a/examples/complex-tool-schema/McpEventScheduler.php +++ b/examples/server/complex-tool-schema/McpEventScheduler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexToolSchema; +namespace Mcp\Example\Server\ComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\ComplexToolSchema\Model\EventPriority; -use Mcp\Example\ComplexToolSchema\Model\EventType; +use Mcp\Example\Server\ComplexToolSchema\Model\EventPriority; +use Mcp\Example\Server\ComplexToolSchema\Model\EventType; use Psr\Log\LoggerInterface; final class McpEventScheduler diff --git a/examples/complex-tool-schema/Model/EventPriority.php b/examples/server/complex-tool-schema/Model/EventPriority.php similarity index 86% rename from examples/complex-tool-schema/Model/EventPriority.php rename to examples/server/complex-tool-schema/Model/EventPriority.php index e46a69da..eb046eff 100644 --- a/examples/complex-tool-schema/Model/EventPriority.php +++ b/examples/server/complex-tool-schema/Model/EventPriority.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexToolSchema\Model; +namespace Mcp\Example\Server\ComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/complex-tool-schema/Model/EventType.php b/examples/server/complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/complex-tool-schema/Model/EventType.php rename to examples/server/complex-tool-schema/Model/EventType.php index eaf0f431..2cd9e5ed 100644 --- a/examples/complex-tool-schema/Model/EventType.php +++ b/examples/server/complex-tool-schema/Model/EventType.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexToolSchema\Model; +namespace Mcp\Example\Server\ComplexToolSchema\Model; enum EventType: string { diff --git a/examples/complex-tool-schema/server.php b/examples/server/complex-tool-schema/server.php similarity index 100% rename from examples/complex-tool-schema/server.php rename to examples/server/complex-tool-schema/server.php diff --git a/examples/custom-dependencies/McpTaskHandlers.php b/examples/server/custom-dependencies/McpTaskHandlers.php similarity index 93% rename from examples/custom-dependencies/McpTaskHandlers.php rename to examples/server/custom-dependencies/McpTaskHandlers.php index 00127a78..affa2a7b 100644 --- a/examples/custom-dependencies/McpTaskHandlers.php +++ b/examples/server/custom-dependencies/McpTaskHandlers.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomDependencies; +namespace Mcp\Example\Server\CustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\Server\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\Server\CustomDependencies\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; /** diff --git a/examples/custom-dependencies/Service/InMemoryTaskRepository.php b/examples/server/custom-dependencies/Service/InMemoryTaskRepository.php similarity index 97% rename from examples/custom-dependencies/Service/InMemoryTaskRepository.php rename to examples/server/custom-dependencies/Service/InMemoryTaskRepository.php index dbf3b6ab..95f77912 100644 --- a/examples/custom-dependencies/Service/InMemoryTaskRepository.php +++ b/examples/server/custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomDependencies\Service; +namespace Mcp\Example\Server\CustomDependencies\Service; use Psr\Log\LoggerInterface; diff --git a/examples/custom-dependencies/Service/StatsServiceInterface.php b/examples/server/custom-dependencies/Service/StatsServiceInterface.php similarity index 87% rename from examples/custom-dependencies/Service/StatsServiceInterface.php rename to examples/server/custom-dependencies/Service/StatsServiceInterface.php index 079f7e23..85bf9b34 100644 --- a/examples/custom-dependencies/Service/StatsServiceInterface.php +++ b/examples/server/custom-dependencies/Service/StatsServiceInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomDependencies\Service; +namespace Mcp\Example\Server\CustomDependencies\Service; interface StatsServiceInterface { diff --git a/examples/custom-dependencies/Service/SystemStatsService.php b/examples/server/custom-dependencies/Service/SystemStatsService.php similarity index 94% rename from examples/custom-dependencies/Service/SystemStatsService.php rename to examples/server/custom-dependencies/Service/SystemStatsService.php index 5cd44880..1cc9beed 100644 --- a/examples/custom-dependencies/Service/SystemStatsService.php +++ b/examples/server/custom-dependencies/Service/SystemStatsService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomDependencies\Service; +namespace Mcp\Example\Server\CustomDependencies\Service; final class SystemStatsService implements StatsServiceInterface { diff --git a/examples/custom-dependencies/Service/TaskRepositoryInterface.php b/examples/server/custom-dependencies/Service/TaskRepositoryInterface.php similarity index 92% rename from examples/custom-dependencies/Service/TaskRepositoryInterface.php rename to examples/server/custom-dependencies/Service/TaskRepositoryInterface.php index 7216634c..b1d43ce1 100644 --- a/examples/custom-dependencies/Service/TaskRepositoryInterface.php +++ b/examples/server/custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomDependencies\Service; +namespace Mcp\Example\Server\CustomDependencies\Service; /** * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} diff --git a/examples/custom-dependencies/server.php b/examples/server/custom-dependencies/server.php similarity index 78% rename from examples/custom-dependencies/server.php rename to examples/server/custom-dependencies/server.php index cd450e51..835a84b7 100644 --- a/examples/custom-dependencies/server.php +++ b/examples/server/custom-dependencies/server.php @@ -13,10 +13,10 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\CustomDependencies\Service\InMemoryTaskRepository; -use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\CustomDependencies\Service\SystemStatsService; -use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\Server\CustomDependencies\Service\InMemoryTaskRepository; +use Mcp\Example\Server\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\Server\CustomDependencies\Service\SystemStatsService; +use Mcp\Example\Server\CustomDependencies\Service\TaskRepositoryInterface; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; diff --git a/examples/custom-method-handlers/CallToolRequestHandler.php b/examples/server/custom-method-handlers/CallToolRequestHandler.php similarity index 97% rename from examples/custom-method-handlers/CallToolRequestHandler.php rename to examples/server/custom-method-handlers/CallToolRequestHandler.php index 22d95b39..6714cb5f 100644 --- a/examples/custom-method-handlers/CallToolRequestHandler.php +++ b/examples/server/custom-method-handlers/CallToolRequestHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomMethodHandlers; +namespace Mcp\Example\Server\CustomMethodHandlers; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; diff --git a/examples/custom-method-handlers/ListToolsRequestHandler.php b/examples/server/custom-method-handlers/ListToolsRequestHandler.php similarity index 96% rename from examples/custom-method-handlers/ListToolsRequestHandler.php rename to examples/server/custom-method-handlers/ListToolsRequestHandler.php index 498f3a89..e97ade55 100644 --- a/examples/custom-method-handlers/ListToolsRequestHandler.php +++ b/examples/server/custom-method-handlers/ListToolsRequestHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CustomMethodHandlers; +namespace Mcp\Example\Server\CustomMethodHandlers; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; diff --git a/examples/custom-method-handlers/server.php b/examples/server/custom-method-handlers/server.php similarity index 100% rename from examples/custom-method-handlers/server.php rename to examples/server/custom-method-handlers/server.php diff --git a/examples/discovery-calculator/McpElements.php b/examples/server/discovery-calculator/McpElements.php similarity index 99% rename from examples/discovery-calculator/McpElements.php rename to examples/server/discovery-calculator/McpElements.php index 972534d1..cabd3101 100644 --- a/examples/discovery-calculator/McpElements.php +++ b/examples/server/discovery-calculator/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DiscoveryCalculator; +namespace Mcp\Example\Server\DiscoveryCalculator; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/discovery-calculator/server.php b/examples/server/discovery-calculator/server.php similarity index 100% rename from examples/discovery-calculator/server.php rename to examples/server/discovery-calculator/server.php diff --git a/examples/discovery-userprofile/McpElements.php b/examples/server/discovery-userprofile/McpElements.php similarity index 98% rename from examples/discovery-userprofile/McpElements.php rename to examples/server/discovery-userprofile/McpElements.php index 8418f09a..8ab6c7d4 100644 --- a/examples/discovery-userprofile/McpElements.php +++ b/examples/server/discovery-userprofile/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DiscoveryUserProfile; +namespace Mcp\Example\Server\DiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; diff --git a/examples/discovery-userprofile/UserIdCompletionProvider.php b/examples/server/discovery-userprofile/UserIdCompletionProvider.php similarity index 92% rename from examples/discovery-userprofile/UserIdCompletionProvider.php rename to examples/server/discovery-userprofile/UserIdCompletionProvider.php index 69dfe4f0..03d97ef3 100644 --- a/examples/discovery-userprofile/UserIdCompletionProvider.php +++ b/examples/server/discovery-userprofile/UserIdCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DiscoveryUserProfile; +namespace Mcp\Example\Server\DiscoveryUserProfile; use Mcp\Capability\Completion\ProviderInterface; diff --git a/examples/discovery-userprofile/server.php b/examples/server/discovery-userprofile/server.php similarity index 100% rename from examples/discovery-userprofile/server.php rename to examples/server/discovery-userprofile/server.php diff --git a/examples/env-variables/EnvToolHandler.php b/examples/server/env-variables/EnvToolHandler.php similarity index 97% rename from examples/env-variables/EnvToolHandler.php rename to examples/server/env-variables/EnvToolHandler.php index f7cad817..7c6cc8df 100644 --- a/examples/env-variables/EnvToolHandler.php +++ b/examples/server/env-variables/EnvToolHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\EnvVariables; +namespace Mcp\Example\Server\EnvVariables; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/env-variables/server.php b/examples/server/env-variables/server.php similarity index 100% rename from examples/env-variables/server.php rename to examples/server/env-variables/server.php diff --git a/examples/explicit-registration/SimpleHandlers.php b/examples/server/explicit-registration/SimpleHandlers.php similarity index 97% rename from examples/explicit-registration/SimpleHandlers.php rename to examples/server/explicit-registration/SimpleHandlers.php index 0fe385c1..0f18a33c 100644 --- a/examples/explicit-registration/SimpleHandlers.php +++ b/examples/server/explicit-registration/SimpleHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ExplicitRegistration; +namespace Mcp\Example\Server\ExplicitRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/explicit-registration/server.php b/examples/server/explicit-registration/server.php similarity index 96% rename from examples/explicit-registration/server.php rename to examples/server/explicit-registration/server.php index 5a61feef..977ee439 100644 --- a/examples/explicit-registration/server.php +++ b/examples/server/explicit-registration/server.php @@ -13,7 +13,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\ExplicitRegistration\SimpleHandlers; +use Mcp\Example\Server\ExplicitRegistration\SimpleHandlers; use Mcp\Schema\ServerCapabilities; use Mcp\Server; diff --git a/examples/schema-showcase/SchemaShowcaseElements.php b/examples/server/schema-showcase/SchemaShowcaseElements.php similarity index 99% rename from examples/schema-showcase/SchemaShowcaseElements.php rename to examples/server/schema-showcase/SchemaShowcaseElements.php index 6c7a4b93..35671cf1 100644 --- a/examples/schema-showcase/SchemaShowcaseElements.php +++ b/examples/server/schema-showcase/SchemaShowcaseElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\SchemaShowcase; +namespace Mcp\Example\Server\SchemaShowcase; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; diff --git a/examples/schema-showcase/server.php b/examples/server/schema-showcase/server.php similarity index 100% rename from examples/schema-showcase/server.php rename to examples/server/schema-showcase/server.php diff --git a/src/Client/Builder.php b/src/Client/Builder.php new file mode 100644 index 00000000..79def324 --- /dev/null +++ b/src/Client/Builder.php @@ -0,0 +1,157 @@ + + */ +class Builder +{ + private string $name = 'mcp-php-client'; + private string $version = '1.0.0'; + private ?string $description = null; + private ?string $protocolVersion = null; + private ?ClientCapabilities $capabilities = null; + private int $initTimeout = 30; + private int $requestTimeout = 120; + private int $maxRetries = 3; + private ?LoggerInterface $logger = null; + + /** @var NotificationHandlerInterface[] */ + private array $notificationHandlers = []; + + /** @var RequestHandlerInterface[] */ + private array $requestHandlers = []; + + /** + * Set the client name and version. + */ + public function setClientInfo(string $name, string $version, ?string $description = null): self + { + $this->name = $name; + $this->version = $version; + $this->description = $description; + + return $this; + } + + /** + * Set the protocol version to use. + */ + public function setProtocolVersion(string $version): self + { + $this->protocolVersion = $version; + + return $this; + } + + /** + * Set client capabilities. + */ + public function setCapabilities(ClientCapabilities $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** + * Set initialization timeout in seconds. + */ + public function setInitTimeout(int $seconds): self + { + $this->initTimeout = $seconds; + + return $this; + } + + /** + * Set request timeout in seconds. + */ + public function setRequestTimeout(int $seconds): self + { + $this->requestTimeout = $seconds; + + return $this; + } + + /** + * Set maximum retry attempts for failed connections. + */ + public function setMaxRetries(int $retries): self + { + $this->maxRetries = $retries; + + return $this; + } + + /** + * Set the logger. + */ + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + /** + * Add a notification handler for server notifications. + */ + public function addNotificationHandler(NotificationHandlerInterface $handler): self + { + $this->notificationHandlers[] = $handler; + + return $this; + } + + /** + * Add a request handler for server requests (e.g., sampling). + */ + public function addRequestHandler(RequestHandlerInterface $handler): self + { + $this->requestHandlers[] = $handler; + + return $this; + } + + /** + * Build the client instance. + */ + public function build(): Client + { + $clientInfo = new Implementation( + $this->name, + $this->version, + $this->description, + ); + + $config = new Configuration( + clientInfo: $clientInfo, + capabilities: $this->capabilities ?? new ClientCapabilities(), + protocolVersion: $this->protocolVersion ?? '2025-06-18', + initTimeout: $this->initTimeout, + requestTimeout: $this->requestTimeout, + maxRetries: $this->maxRetries, + ); + + return new Client($config, $this->notificationHandlers, $this->requestHandlers, $this->logger); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 00000000..5844065f --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,318 @@ + + */ +class Client +{ + private Protocol $protocol; + private ClientSessionInterface $session; + private ?ClientTransportInterface $transport = null; + + /** + * @param NotificationHandlerInterface[] $notificationHandlers + * @param RequestHandlerInterface[] $requestHandlers + */ + public function __construct( + private readonly Configuration $config, + array $notificationHandlers = [], + array $requestHandlers = [], + ?LoggerInterface $logger = null, + ) { + $this->session = new ClientSession(); + + $allNotificationHandlers = [ + new ProgressNotificationHandler($this->session), + ...$notificationHandlers, + ]; + + $this->protocol = new Protocol( + $this->session, + $config, + $allNotificationHandlers, + $requestHandlers, + null, + $logger + ); + } + + /** + * Create a new client builder for fluent configuration. + */ + public static function builder(): Builder + { + return new Builder(); + } + + /** + * Connect to an MCP server using the provided transport. + * + * This method blocks until initialization completes or times out. + * The transport handles all blocking operations internally. + * + * @throws ConnectionException If connection or initialization fails + */ + public function connect(ClientTransportInterface $transport): void + { + $this->transport = $transport; + $this->protocol->connect($transport); + + $transport->connectAndInitialize($this->config->initTimeout); + } + + /** + * Check if connected and initialized. + */ + public function isConnected(): bool + { + return null !== $this->transport && $this->protocol->getSession()->isInitialized(); + } + + /** + * Ping the server. + */ + public function ping(): void + { + $this->ensureConnected(); + $this->doRequest(new PingRequest()); + } + + /** + * List available tools from the server. + */ + public function listTools(?string $cursor = null): ListToolsResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListToolsRequest($cursor), ListToolsResult::class); + } + + /** + * Call a tool on the server. + * + * @param string $name Tool name + * @param array $arguments Tool arguments + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates. If provided, a progress token + * is automatically generated and attached to the request. + */ + public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult + { + $this->ensureConnected(); + + $request = new CallToolRequest($name, $arguments); + + return $this->doRequest($request, CallToolResult::class, $onProgress); + } + + /** + * List available resources. + */ + public function listResources(?string $cursor = null): ListResourcesResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListResourcesRequest($cursor), ListResourcesResult::class); + } + + /** + * List available resource templates. + */ + public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListResourceTemplatesRequest($cursor), ListResourceTemplatesResult::class); + } + + /** + * Read a resource by URI. + * + * @param string $uri The resource URI + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates. + */ + public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult + { + $this->ensureConnected(); + + $request = new ReadResourceRequest($uri); + + return $this->doRequest($request, ReadResourceResult::class, $onProgress); + } + + /** + * List available prompts. + */ + public function listPrompts(?string $cursor = null): ListPromptsResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListPromptsRequest($cursor), ListPromptsResult::class); + } + + /** + * Get a prompt by name. + * + * @param string $name Prompt name + * @param array $arguments Prompt arguments + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates. + */ + public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult + { + $this->ensureConnected(); + + $request = new GetPromptRequest($name, $arguments); + + return $this->doRequest($request, GetPromptResult::class, $onProgress); + } + + /** + * Set the minimum logging level for server notifications. + */ + public function setLoggingLevel(LoggingLevel $level): void + { + $this->ensureConnected(); + $this->doRequest(new SetLogLevelRequest($level)); + } + + /** + * Request completion suggestions for a prompt or resource argument. + * + * @param PromptReference|ResourceReference $ref The prompt or resource reference + * @param array{name: string, value: string} $argument The argument to complete + */ + public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult + { + $this->ensureConnected(); + + return $this->doRequest( + new CompletionCompleteRequest($ref, $argument), + CompletionCompleteResult::class, + ); + } + + /** + * Get the server info received during initialization. + */ + public function getServerInfo(): ?Implementation + { + return $this->protocol->getSession()->getServerInfo(); + } + + /** + * Get the server instructions received during initialization. + * + * Instructions describe how to use the server and its features. + * This can be used to improve the LLM's understanding of available tools and resources. + */ + public function getInstructions(): ?string + { + return $this->protocol->getSession()->getInstructions(); + } + + /** + * Disconnect from the server. + */ + public function disconnect(): void + { + $this->transport?->close(); + $this->transport = null; + } + + /** + * Execute a request and return the typed result. + * + * @template T + * + * @param class-string|null $resultClass + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * + * @return T|Response> + * + * @throws RequestException + */ + private function doRequest(Request $request, ?string $resultClass = null, ?callable $onProgress = null): mixed + { + $requestId = $this->session->nextRequestId(); + $request = $request->withId($requestId); + + if (null !== $onProgress) { + $progressToken = 'prog-' . $requestId; + $request = $request->withMeta(['progressToken' => $progressToken]); + } + + $fiber = new \Fiber(fn() => $this->protocol->request($request, $this->config->requestTimeout)); + + $response = $this->transport->runRequest($fiber, $onProgress); + + if ($response instanceof Error) { + throw RequestException::fromError($response); + } + + if (null === $resultClass) { + return $response; + } + + return $resultClass::fromArray($response->result); + } + + private function ensureConnected(): void + { + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + } +} diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php new file mode 100644 index 00000000..c328ba76 --- /dev/null +++ b/src/Client/Configuration.php @@ -0,0 +1,33 @@ + + */ +class Configuration +{ + public function __construct( + public readonly Implementation $clientInfo, + public readonly ClientCapabilities $capabilities, + public readonly string $protocolVersion = '2025-06-18', + public readonly int $initTimeout = 30, + public readonly int $requestTimeout = 120, + public readonly int $maxRetries = 3, + ) { + } +} diff --git a/src/Client/Handler/LoggingNotificationHandler.php b/src/Client/Handler/LoggingNotificationHandler.php new file mode 100644 index 00000000..4e206c71 --- /dev/null +++ b/src/Client/Handler/LoggingNotificationHandler.php @@ -0,0 +1,42 @@ + + */ +class LoggingNotificationHandler implements NotificationHandlerInterface +{ + /** + * @param callable(LoggingMessageNotification): void $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof LoggingMessageNotification; + } + + public function handle(Notification $notification): void + { + ($this->callback)($notification); + } +} diff --git a/src/Client/Handler/ProgressNotificationHandler.php b/src/Client/Handler/ProgressNotificationHandler.php new file mode 100644 index 00000000..bda2285c --- /dev/null +++ b/src/Client/Handler/ProgressNotificationHandler.php @@ -0,0 +1,53 @@ + + * + * @internal + */ +class ProgressNotificationHandler implements NotificationHandlerInterface +{ + public function __construct( + private readonly ClientSessionInterface $session, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof ProgressNotification; + } + + public function handle(Notification $notification): void + { + if (!$notification instanceof ProgressNotification) { + return; + } + + $this->session->storeProgress( + (string) $notification->progressToken, + $notification->progress, + $notification->total, + $notification->message, + ); + } +} diff --git a/src/Client/Handler/SamplingRequestHandler.php b/src/Client/Handler/SamplingRequestHandler.php new file mode 100644 index 00000000..d3a592e1 --- /dev/null +++ b/src/Client/Handler/SamplingRequestHandler.php @@ -0,0 +1,55 @@ +> + * + * @author Kyrian Obikwelu + */ +class SamplingRequestHandler implements RequestHandlerInterface +{ + /** + * @param callable(CreateSamplingMessageRequest): CreateSamplingMessageResult $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CreateSamplingMessageRequest; + } + + /** + * @return array + */ + public function handle(Request $request): array + { + assert($request instanceof CreateSamplingMessageRequest); + + $result = ($this->callback)($request); + + return $result->jsonSerialize(); + } +} diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php new file mode 100644 index 00000000..cc0865fe --- /dev/null +++ b/src/Client/Protocol.php @@ -0,0 +1,299 @@ + + */ +class Protocol +{ + private ?ClientTransportInterface $transport = null; + private MessageFactory $messageFactory; + private LoggerInterface $logger; + + /** + * @param NotificationHandlerInterface[] $notificationHandlers + * @param RequestHandlerInterface[] $requestHandlers + */ + public function __construct( + private readonly ClientSessionInterface $session, + private readonly Configuration $config, + private readonly array $notificationHandlers = [], + private readonly array $requestHandlers = [], + ?MessageFactory $messageFactory = null, + ?LoggerInterface $logger = null, + ) { + $this->messageFactory = $messageFactory ?? MessageFactory::make(); + $this->logger = $logger ?? new NullLogger(); + } + + /** + * Connect this protocol to a transport. + * + * Sets up message handling callbacks. + */ + public function connect(ClientTransportInterface $transport): void + { + $this->transport = $transport; + $transport->setSession($this->session); + $transport->onInitialize($this->initialize(...)); + $transport->onMessage($this->processMessage(...)); + $transport->onError(fn(\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); + } + + /** + * Perform the MCP initialization handshake. + * + * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * + * @return Response>|Error + */ + public function initialize(): Response|Error + { + $request = new InitializeRequest( + $this->config->protocolVersion, + $this->config->capabilities, + $this->config->clientInfo, + ); + + $requestId = $this->session->nextRequestId(); + $request = $request->withId($requestId); + + $response = $this->request($request, $this->config->initTimeout); + + if ($response instanceof Response) { + $initResult = InitializeResult::fromArray($response->result); + $this->session->setServerInfo($initResult->serverInfo); + $this->session->setInstructions($initResult->instructions); + $this->session->setInitialized(true); + + $this->sendNotification(new InitializedNotification()); + + $this->logger->info('Initialization complete', [ + 'server' => $initResult->serverInfo->name, + ]); + } + + return $response; + } + + /** + * Send a request to the server. + * + * If a response is immediately available (sync HTTP), returns it. + * Otherwise, suspends the Fiber and waits for the transport to resume it. + * + * @return Response>|Error + */ + public function request(Request $request, int $timeout): Response|Error + { + $requestId = $request->getId(); + + $this->logger->debug('Sending request', [ + 'id' => $requestId, + 'method' => $request::getMethod(), + ]); + + $encoded = json_encode($request, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'request']); + $this->session->addPendingRequest($requestId, $timeout); + + $this->flushOutgoing(); + + $immediate = $this->session->consumeResponse($requestId); + if (null !== $immediate) { + $this->logger->debug('Received immediate response', ['id' => $requestId]); + + return $immediate; + } + + $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + + return \Fiber::suspend([ + 'type' => 'await_response', + 'request_id' => $requestId, + 'timeout' => $timeout, + ]); + } + + /** + * Send a notification to the server (fire and forget). + */ + public function sendNotification(Notification $notification): void + { + $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + + $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'notification']); + $this->flushOutgoing(); + } + + /** + * Process an incoming message from the server. + * + * Routes to appropriate handler based on message type. + */ + public function processMessage(string $input): void + { + $this->logger->debug('Received message', ['input' => $input]); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to parse message', ['exception' => $e]); + + return; + } + + foreach ($messages as $message) { + if ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message); + } elseif ($message instanceof Request) { + $this->handleServerRequest($message); + } elseif ($message instanceof Notification) { + $this->handleServerNotification($message); + } + } + } + + /** + * Handle a response from the server. + * + * This stores it in session. The transport will pick it up and resume the Fiber. + */ + private function handleResponse(Response|Error $response): void + { + $requestId = $response->getId(); + + $this->logger->debug('Handling response', ['id' => $requestId]); + + if ($response instanceof Response) { + $this->session->storeResponse($requestId, $response->jsonSerialize()); + } else { + $this->session->storeResponse($requestId, $response->jsonSerialize()); + } + } + + /** + * Handle a request from the server (e.g., sampling request). + */ + private function handleServerRequest(Request $request): void + { + $method = $request::getMethod(); + + $this->logger->debug('Received server request', [ + 'method' => $method, + 'id' => $request->getId(), + ]); + + foreach ($this->requestHandlers as $handler) { + if ($handler->supports($request)) { + try { + $result = $handler->handle($request); + + $response = new Response($request->getId(), $result); + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'response']); + $this->flushOutgoing(); + + return; + } catch (\Throwable $e) { + $this->logger->warning('Request handler failed', ['exception' => $e]); + + $error = Error::forInternalError($e->getMessage(), $request->getId()); + $encoded = json_encode($error, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'error']); + $this->flushOutgoing(); + + return; + } + } + } + + $error = Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $method), + $request->getId() + ); + + $encoded = json_encode($error, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'error']); + $this->flushOutgoing(); + } + + /** + * Handle a notification from the server. + */ + private function handleServerNotification(Notification $notification): void + { + $method = $notification::getMethod(); + + $this->logger->debug('Received server notification', [ + 'method' => $method, + ]); + + foreach ($this->notificationHandlers as $handler) { + if ($handler->supports($notification)) { + try { + $handler->handle($notification); + } catch (\Throwable $e) { + $this->logger->warning('Notification handler failed', ['exception' => $e]); + } + + return; + } + } + } + + /** + * Flush any queued outgoing messages. + */ + private function flushOutgoing(): void + { + if (null === $this->transport) { + return; + } + + $messages = $this->session->consumeOutgoingMessages(); + foreach ($messages as $item) { + $this->transport->send($item['message'], $item['context']); + } + } + + public function getSession(): ClientSessionInterface + { + return $this->session; + } +} diff --git a/src/Client/Session/ClientSession.php b/src/Client/Session/ClientSession.php new file mode 100644 index 00000000..f048c5f2 --- /dev/null +++ b/src/Client/Session/ClientSession.php @@ -0,0 +1,163 @@ + + */ +class ClientSession implements ClientSessionInterface +{ + private Uuid $id; + private int $requestIdCounter = 1; + private bool $initialized = false; + private ?Implementation $serverInfo = null; + private ?string $instructions = null; + + /** @var array */ + private array $pendingRequests = []; + + /** @var array> */ + private array $responses = []; + + /** @var array}> */ + private array $outgoingQueue = []; + + /** @var array */ + private array $progressUpdates = []; + + public function __construct(?Uuid $id = null) + { + $this->id = $id ?? Uuid::v4(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function nextRequestId(): int + { + return $this->requestIdCounter++; + } + + public function addPendingRequest(int $requestId, int $timeout): void + { + $this->pendingRequests[$requestId] = [ + 'request_id' => $requestId, + 'timestamp' => time(), + 'timeout' => $timeout, + ]; + } + + public function removePendingRequest(int $requestId): void + { + unset($this->pendingRequests[$requestId]); + } + + public function getPendingRequests(): array + { + return $this->pendingRequests; + } + + public function storeResponse(int $requestId, array $responseData): void + { + $this->responses[$requestId] = $responseData; + } + + public function consumeResponse(int $requestId): Response|Error|null + { + if (!isset($this->responses[$requestId])) { + return null; + } + + $data = $this->responses[$requestId]; + unset($this->responses[$requestId]); + $this->removePendingRequest($requestId); + + if (isset($data['error'])) { + return Error::fromArray($data); + } + + return Response::fromArray($data); + } + + public function queueOutgoing(string $message, array $context): void + { + $this->outgoingQueue[] = [ + 'message' => $message, + 'context' => $context, + ]; + } + + public function consumeOutgoingMessages(): array + { + $messages = $this->outgoingQueue; + $this->outgoingQueue = []; + + return $messages; + } + + public function setInitialized(bool $initialized): void + { + $this->initialized = $initialized; + } + + public function isInitialized(): bool + { + return $this->initialized; + } + + public function setServerInfo(Implementation $serverInfo): void + { + $this->serverInfo = $serverInfo; + } + + public function getServerInfo(): ?Implementation + { + return $this->serverInfo; + } + + public function setInstructions(?string $instructions): void + { + $this->instructions = $instructions; + } + + public function getInstructions(): ?string + { + return $this->instructions; + } + + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void + { + $this->progressUpdates[] = [ + 'token' => $token, + 'progress' => $progress, + 'total' => $total, + 'message' => $message, + ]; + } + + public function consumeProgressUpdates(): array + { + $updates = $this->progressUpdates; + $this->progressUpdates = []; + + return $updates; + } +} diff --git a/src/Client/Session/ClientSessionInterface.php b/src/Client/Session/ClientSessionInterface.php new file mode 100644 index 00000000..00106ac3 --- /dev/null +++ b/src/Client/Session/ClientSessionInterface.php @@ -0,0 +1,134 @@ + + */ +interface ClientSessionInterface +{ + /** + * Get the session ID. + */ + public function getId(): Uuid; + + /** + * Get the next request ID for outgoing requests. + */ + public function nextRequestId(): int; + + /** + * Add a pending request to track. + * + * @param int $requestId The request ID + * @param int $timeout Timeout in seconds + */ + public function addPendingRequest(int $requestId, int $timeout): void; + + /** + * Remove a pending request. + */ + public function removePendingRequest(int $requestId): void; + + /** + * Get all pending requests. + * + * @return array + */ + public function getPendingRequests(): array; + + /** + * Store a received response. + * + * @param int $requestId The request ID + * @param array $responseData The raw response data + */ + public function storeResponse(int $requestId, array $responseData): void; + + /** + * Check and consume a response for a request ID. + * + * @return Response>|Error|null + */ + public function consumeResponse(int $requestId): Response|Error|null; + + /** + * Queue an outgoing message. + * + * @param string $message JSON-encoded message + * @param array $context Message context + */ + public function queueOutgoing(string $message, array $context): void; + + /** + * Get and clear all queued outgoing messages. + * + * @return array}> + */ + public function consumeOutgoingMessages(): array; + + /** + * Set initialization state. + */ + public function setInitialized(bool $initialized): void; + + /** + * Check if session is initialized. + */ + public function isInitialized(): bool; + + /** + * Store the server info from initialization. + */ + public function setServerInfo(Implementation $serverInfo): void; + + /** + * Get the server info from initialization. + */ + public function getServerInfo(): ?Implementation; + + /** + * Store the server instructions from initialization. + */ + public function setInstructions(?string $instructions): void; + + /** + * Get the server instructions from initialization. + */ + public function getInstructions(): ?string; + + /** + * Store progress data received from a notification. + * + * @param string $token The progress token + * @param float $progress Current progress value + * @param float|null $total Total progress value (if known) + * @param string|null $message Progress message + */ + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void; + + /** + * Consume all pending progress updates. + * + * @return array + */ + public function consumeProgressUpdates(): array; +} diff --git a/src/Client/Transport/BaseClientTransport.php b/src/Client/Transport/BaseClientTransport.php new file mode 100644 index 00000000..5229ed9c --- /dev/null +++ b/src/Client/Transport/BaseClientTransport.php @@ -0,0 +1,126 @@ + + */ +abstract class BaseClientTransport implements ClientTransportInterface +{ + /** @var callable(): mixed|null */ + protected $initializeCallback = null; + + /** @var callable(string): void|null */ + protected $messageCallback = null; + + /** @var callable(\Throwable): void|null */ + protected $errorCallback = null; + + /** @var callable(string): void|null */ + protected $closeCallback = null; + + + protected ?ClientSessionInterface $session = null; + protected LoggerInterface $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + + public function onInitialize(callable $listener): void + { + $this->initializeCallback = $listener; + } + + public function onMessage(callable $listener): void + { + $this->messageCallback = $listener; + } + + public function onError(callable $listener): void + { + $this->errorCallback = $listener; + } + + public function onClose(callable $listener): void + { + $this->closeCallback = $listener; + } + + public function setSession(ClientSessionInterface $session): void + { + $this->session = $session; + } + + /** + * Perform initialization via the registered callback. + * + * @return mixed The result from the initialization callback + * + * @throws \RuntimeException If no initialize listener is registered + */ + protected function handleInitialize(): mixed + { + if (!\is_callable($this->initializeCallback)) { + throw new \RuntimeException('No initialize listener registered'); + } + + return ($this->initializeCallback)(); + } + + /** + * Handle an incoming message from the server. + */ + protected function handleMessage(string $message): void + { + if (\is_callable($this->messageCallback)) { + try { + ($this->messageCallback)($message); + } catch (\Throwable $e) { + $this->handleError($e); + } + } + } + + /** + * Handle a transport error. + */ + protected function handleError(\Throwable $error): void + { + $this->logger->error('Transport error', ['exception' => $error]); + + if (\is_callable($this->errorCallback)) { + ($this->errorCallback)($error); + } + } + + /** + * Handle connection close. + */ + protected function handleClose(string $reason): void + { + $this->logger->info('Transport closed', ['reason' => $reason]); + + if (\is_callable($this->closeCallback)) { + ($this->closeCallback)($reason); + } + } +} diff --git a/src/Client/Transport/ClientTransportInterface.php b/src/Client/Transport/ClientTransportInterface.php new file mode 100644 index 00000000..f7618a42 --- /dev/null +++ b/src/Client/Transport/ClientTransportInterface.php @@ -0,0 +1,115 @@ +|Error) + * @phpstan-type FiberResume (FiberReturn|null) + * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int} + * @phpstan-type McpFiber \Fiber + * + * @author Kyrian Obikwelu + */ +interface ClientTransportInterface +{ + /** + * Connect to the MCP server and perform initialization handshake. + * + * This method blocks until: + * - Initialization completes successfully + * - Timeout is reached (throws TimeoutException) + * - Connection fails (throws ConnectionException) + * + * @param int $timeout Maximum time to wait for initialization (seconds) + * + * @throws \Mcp\Exception\TimeoutException + * @throws \Mcp\Exception\ConnectionException + */ + public function connectAndInitialize(int $timeout): void; + + /** + * Send a message to the server immediately. + * + * @param string $data JSON-encoded message + * @param array $context Message context (type, etc.) + */ + public function send(string $data, array $context): void; + + /** + * Run a request fiber to completion. + * + * The transport starts the fiber, runs its internal loop, and resumes + * the fiber when a response arrives or timeout occurs. + * + * During the loop, the transport checks session for progress data and + * executes the callback if provided. + * + * @param McpFiber $fiber The fiber to execute + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + * + * @return Response>|Error The response or error + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error; + + /** + * Close the transport and clean up resources. + */ + public function close(): void; + + /** + * Register callback for initialization handshake. + * + * The callback should return a Fiber that performs the initialization. + * + * @param callable(): mixed $callback + */ + public function onInitialize(callable $callback): void; + + /** + * Register callback for incoming messages from server. + * + * @param callable(string $message): void $callback + */ + public function onMessage(callable $callback): void; + + /** + * Register callback for transport errors. + * + * @param callable(\Throwable $error): void $callback + */ + public function onError(callable $callback): void; + + /** + * Register callback for when connection closes. + * + * @param callable(string $reason): void $callback + */ + public function onClose(callable $callback): void; + + /** + * Set the client session for state management. + */ + public function setSession(ClientSessionInterface $session): void; + +} diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpClientTransport.php new file mode 100644 index 00000000..ec7ad009 --- /dev/null +++ b/src/Client/Transport/HttpClientTransport.php @@ -0,0 +1,314 @@ + + */ +class HttpClientTransport extends BaseClientTransport +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + private ?string $sessionId = null; + private bool $running = false; + + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback = null; + + /** @var StreamInterface|null Active SSE stream being read */ + private ?StreamInterface $activeStream = null; + + /** @var string Buffer for incomplete SSE data */ + private string $sseBuffer = ''; + + /** + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + private readonly string $endpoint, + private readonly array $headers = [], + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function connectAndInitialize(int $timeout): void + { + $this->running = true; + + $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + + $deadline = time() + $timeout; + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + if (time() >= $deadline) { + $this->running = false; + throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + } + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + $this->running = false; + throw new ConnectionException('Initialization failed: ' . $result->message); + } + + $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); + } + + public function send(string $data, array $context): void + { + $request = $this->requestFactory->createRequest('POST', $this->endpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->streamFactory->createStream($data)); + + if (null !== $this->sessionId) { + $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); + } + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->logger->debug('Sending HTTP request', ['data' => $data]); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + $this->handleError($e); + throw new ConnectionException('HTTP request failed: ' . $e->getMessage(), 0, $e); + } + + if ($response->hasHeader('Mcp-Session-Id')) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); + } + + $contentType = $response->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'text/event-stream')) { + $this->activeStream = $response->getBody(); + $this->sseBuffer = ''; + } elseif (str_contains($contentType, 'application/json')) { + $body = $response->getBody()->getContents(); + if (!empty($body)) { + $this->handleMessage($body); + } + } + } + + /** + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + if ($fiber->isTerminated()) { + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + + return $fiber->getReturn(); + } + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + $this->running = false; + + if (null !== $this->sessionId) { + try { + $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) + ->withHeader('Mcp-Session-Id', $this->sessionId); + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->httpClient->sendRequest($request); + $this->logger->info('Session closed', ['session_id' => $this->sessionId]); + } catch (\Throwable $e) { + $this->logger->warning('Failed to close session', ['error' => $e->getMessage()]); + } + } + + $this->sessionId = null; + $this->activeStream = null; + $this->handleClose('Transport closed'); + } + + private function tick(): void + { + $this->processSSEStream(); + $this->processProgress(); + $this->processFiber(); + + usleep(1000); // 1ms + } + + /** + * Read SSE data incrementally from active stream. + */ + private function processSSEStream(): void + { + if (null === $this->activeStream) { + return; + } + + if (!$this->activeStream->eof()) { + $chunk = $this->activeStream->read(4096); + if ('' !== $chunk) { + $this->sseBuffer .= $chunk; + } + } + + while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) { + $event = substr($this->sseBuffer, 0, $pos); + $this->sseBuffer = substr($this->sseBuffer, $pos + 2); + + if (!empty(trim($event))) { + $this->processSSEEvent($event); + } + } + + if ($this->activeStream->eof() && empty($this->sseBuffer)) { + $this->activeStream = null; + } + } + + /** + * Parse a single SSE event and handle the message. + */ + private function processSSEEvent(string $event): void + { + $data = ''; + + foreach (explode("\n", $event) as $line) { + if (str_starts_with($line, 'data:')) { + $data .= trim(substr($line, 5)); + } + } + + if (!empty($data)) { + $this->handleMessage($data); + } + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->session) { + return; + } + + $updates = $this->session->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function processFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->session) { + return; + } + + $pendingRequests = $this->session->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + $response = $this->session->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } +} diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioClientTransport.php new file mode 100644 index 00000000..3f3d643f --- /dev/null +++ b/src/Client/Transport/StdioClientTransport.php @@ -0,0 +1,297 @@ + + */ +class StdioClientTransport extends BaseClientTransport +{ + /** @var resource|null */ + private $process = null; + + /** @var resource|null */ + private $stdin = null; + + /** @var resource|null */ + private $stdout = null; + + /** @var resource|null */ + private $stderr = null; + + private string $inputBuffer = ''; + private bool $running = false; + + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback = null; + + /** + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables + */ + public function __construct( + private readonly string $command, + private readonly array $args = [], + private readonly ?string $cwd = null, + private readonly ?array $env = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + } + + public function connectAndInitialize(int $timeout): void + { + $this->spawnProcess(); + + $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + + $deadline = time() + $timeout; + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + if (time() >= $deadline) { + $this->close(); + throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + } + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + $this->close(); + throw new ConnectionException('Initialization failed: ' . $result->message); + } + + $this->logger->info('Client connected and initialized'); + } + + public function send(string $data, array $context): void + { + if (null === $this->stdin || !\is_resource($this->stdin)) { + throw new ConnectionException('Process stdin not available'); + } + + fwrite($this->stdin, $data . "\n"); + fflush($this->stdin); + + $this->logger->debug('Sent message to server', ['data' => $data]); + } + + /** + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + $this->running = false; + + if (\is_resource($this->stdin)) { + fclose($this->stdin); + $this->stdin = null; + } + if (\is_resource($this->stdout)) { + fclose($this->stdout); + $this->stdout = null; + } + if (\is_resource($this->stderr)) { + fclose($this->stderr); + $this->stderr = null; + } + if (\is_resource($this->process)) { + proc_terminate($this->process, 15); // SIGTERM + proc_close($this->process); + $this->process = null; + } + + $this->handleClose('Transport closed'); + } + + private function spawnProcess(): void + { + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $cmd = escapeshellcmd($this->command); + foreach ($this->args as $arg) { + $cmd .= ' ' . escapeshellarg($arg); + } + + $this->process = proc_open( + $cmd, + $descriptors, + $pipes, + $this->cwd, + $this->env + ); + + if (!\is_resource($this->process)) { + throw new ConnectionException('Failed to start process: ' . $cmd); + } + + $this->stdin = $pipes[0]; + $this->stdout = $pipes[1]; + $this->stderr = $pipes[2]; + + // Set non-blocking mode for reading + stream_set_blocking($this->stdout, false); + stream_set_blocking($this->stderr, false); + + $this->running = true; + $this->logger->info('Started MCP server process', ['command' => $cmd]); + } + + private function tick(): void + { + $this->processInput(); + $this->processProgress(); + $this->processFiber(); + $this->processStderr(); + + usleep(1000); // 1ms + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->session) { + return; + } + + $updates = $this->session->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function processInput(): void + { + if (null === $this->stdout || !\is_resource($this->stdout)) { + return; + } + + $data = fread($this->stdout, 8192); + if (false !== $data && '' !== $data) { + $this->inputBuffer .= $data; + } + + while (false !== ($pos = strpos($this->inputBuffer, "\n"))) { + $line = substr($this->inputBuffer, 0, $pos); + $this->inputBuffer = substr($this->inputBuffer, $pos + 1); + + $trimmed = trim($line); + if (!empty($trimmed)) { + $this->handleMessage($trimmed); + } + } + } + + private function processFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->session) { + return; + } + + $pendingRequests = $this->session->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + // Check if response arrived + $response = $this->session->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + // Check timeout + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } + + private function processStderr(): void + { + if (null === $this->stderr || !\is_resource($this->stderr)) { + return; + } + + $stderr = fread($this->stderr, 8192); + if (false !== $stderr && '' !== $stderr) { + $this->logger->debug('Server stderr', ['output' => trim($stderr)]); + } + } +} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 00000000..4e4527f5 --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,21 @@ + + */ +class ConnectionException extends Exception +{ +} diff --git a/src/Exception/RequestException.php b/src/Exception/RequestException.php new file mode 100644 index 00000000..44b8a91f --- /dev/null +++ b/src/Exception/RequestException.php @@ -0,0 +1,40 @@ + + */ +class RequestException extends Exception +{ + private ?Error $error; + + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, ?Error $error = null) + { + parent::__construct($message, $code, $previous); + $this->error = $error; + } + + public static function fromError(Error $error): self + { + return new self($error->message, $error->code, null, $error); + } + + public function getError(): ?Error + { + return $this->error; + } +} diff --git a/src/Exception/TimeoutException.php b/src/Exception/TimeoutException.php new file mode 100644 index 00000000..8fa5c74a --- /dev/null +++ b/src/Exception/TimeoutException.php @@ -0,0 +1,21 @@ + + */ +class TimeoutException extends Exception +{ +} diff --git a/src/Handler/NotificationHandlerInterface.php b/src/Handler/NotificationHandlerInterface.php new file mode 100644 index 00000000..c9cc7483 --- /dev/null +++ b/src/Handler/NotificationHandlerInterface.php @@ -0,0 +1,35 @@ + + */ +interface NotificationHandlerInterface +{ + /** + * Check if this handler supports the given notification. + */ + public function supports(Notification $notification): bool; + + /** + * Handle the notification. + */ + public function handle(Notification $notification): void; +} diff --git a/src/Handler/RequestHandlerInterface.php b/src/Handler/RequestHandlerInterface.php new file mode 100644 index 00000000..58c9d841 --- /dev/null +++ b/src/Handler/RequestHandlerInterface.php @@ -0,0 +1,39 @@ + + */ +interface RequestHandlerInterface +{ + /** + * Check if this handler supports the given request. + */ + public function supports(Request $request): bool; + + /** + * Handle the request and return the result. + * + * @return TResult + */ + public function handle(Request $request): mixed; +} diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 99aae118..3014405e 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -74,17 +74,33 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); } + $messages = []; + foreach ($params['messages'] as $messageData) { + if ($messageData instanceof SamplingMessage) { + $messages[] = $messageData; + } elseif (\is_array($messageData)) { + $messages[] = SamplingMessage::fromArray($messageData); + } else { + throw new InvalidArgumentException('Invalid message format in sampling/createMessage.'); + } + } + $preferences = null; if (isset($params['preferences'])) { $preferences = ModelPreferences::fromArray($params['preferences']); } + $includeContext = null; + if (isset($params['includeContext']) && \is_string($params['includeContext'])) { + $includeContext = SamplingContext::tryFrom($params['includeContext']); + } + return new self( - $params['messages'], + $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, - $params['includeContext'] ?? null, + $includeContext, $params['temperature'] ?? null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index 813e8abd..d7fa4c4b 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -60,4 +60,18 @@ public function jsonSerialize(): array return ['completion' => $completion]; } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $completion = $data['completion'] ?? []; + + return new self( + $completion['values'] ?? [], + $completion['total'] ?? null, + $completion['hasMore'] ?? null, + ); + } } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index e9b3f7ee..f3a07776 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -93,9 +93,15 @@ private function processFiber(): void $pendingRequests = $this->getPendingRequests($this->sessionId); if (empty($pendingRequests)) { + // Flush any queued messages before resuming (e.g., notifications from previous yield) + $this->flushOutgoingMessages(); + $yielded = $this->sessionFiber->resume(); $this->handleFiberYield($yielded, $this->sessionId); + // Flush newly queued messages (like notifications) before returning + $this->flushOutgoingMessages(); + return; } @@ -109,6 +115,7 @@ private function processFiber(): void if (null !== $response) { $yielded = $this->sessionFiber->resume($response); $this->handleFiberYield($yielded, $this->sessionId); + $this->flushOutgoingMessages(); return; } @@ -117,6 +124,7 @@ private function processFiber(): void $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); + $this->flushOutgoingMessages(); return; } @@ -150,7 +158,8 @@ private function flushOutgoingMessages(): void private function writeLine(string $payload): void { - fwrite($this->output, $payload.\PHP_EOL); + fwrite($this->output, $payload . \PHP_EOL); + fflush($this->output); } public function close(): void diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php index ba287fd0..c74d5cb1 100644 --- a/tests/Inspector/Http/HttpClientCommunicationTest.php +++ b/tests/Inspector/Http/HttpClientCommunicationTest.php @@ -59,6 +59,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/client-communication/server.php'; + return \dirname(__DIR__, 3).'/examples/server/client-communication/server.php'; } } diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php index 36f133cd..80778391 100644 --- a/tests/Inspector/Http/HttpCombinedRegistrationTest.php +++ b/tests/Inspector/Http/HttpCombinedRegistrationTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/combined-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/server/combined-registration/server.php'; } } diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php index 6c104e28..1ef58807 100644 --- a/tests/Inspector/Http/HttpComplexToolSchemaTest.php +++ b/tests/Inspector/Http/HttpComplexToolSchemaTest.php @@ -82,6 +82,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/complex-tool-schema/server.php'; + return \dirname(__DIR__, 3).'/examples/server/complex-tool-schema/server.php'; } } diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index b0ffef81..80114396 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -67,6 +67,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/discovery-userprofile/server.php'; + return \dirname(__DIR__, 3).'/examples/server/discovery-userprofile/server.php'; } } diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php index 9ed61ae1..51a00b72 100644 --- a/tests/Inspector/Http/HttpSchemaShowcaseTest.php +++ b/tests/Inspector/Http/HttpSchemaShowcaseTest.php @@ -87,7 +87,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/schema-showcase/server.php'; + return \dirname(__DIR__, 3).'/examples/server/schema-showcase/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php index 438a3f8e..fd0bc908 100644 --- a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php +++ b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php @@ -88,6 +88,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/cached-discovery/server.php'; + return \dirname(__DIR__, 3).'/examples/server/cached-discovery/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php index d2f64c0d..9e1affd0 100644 --- a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php +++ b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php @@ -55,7 +55,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/custom-dependencies/server.php'; + return \dirname(__DIR__, 3).'/examples/server/custom-dependencies/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php index 87ce549c..f44ed6cc 100644 --- a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php +++ b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/discovery-calculator/server.php'; + return \dirname(__DIR__, 3).'/examples/server/discovery-calculator/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php index c46eb753..1c87469c 100644 --- a/tests/Inspector/Stdio/StdioEnvVariablesTest.php +++ b/tests/Inspector/Stdio/StdioEnvVariablesTest.php @@ -50,6 +50,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/env-variables/server.php'; + return \dirname(__DIR__, 3).'/examples/server/env-variables/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php index 1cb8179b..d31c3f24 100644 --- a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php +++ b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php @@ -77,6 +77,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/explicit-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/server/explicit-registration/server.php'; } }