diff --git a/README.md b/README.md index 394cd37c..52469357 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ $server = Server::builder() - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage - [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts +- [Client Communiocation](docs/client-communication.md) - Communicating back to the client from server-side **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs diff --git a/composer.json b/composer.json index dfb304e7..30a94724 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", + "Mcp\\Example\\StdioClientCommunication\\": "examples/stdio-client-communication/", "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", diff --git a/docs/client-communication.md b/docs/client-communication.md new file mode 100644 index 00000000..8da4bc65 --- /dev/null +++ b/docs/client-communication.md @@ -0,0 +1,101 @@ +# Client Communication + +MCP supports various ways a server can communicate back to a server on top of the main request-response flow. + +## Table of Contents + +- [ClientGateway](#client-gateway) +- [Sampling](#sampling) +- [Logging](#logging) +- [Notification](#notification) +- [Progress](#progress) + +## ClientGateway + +Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per +operation. To use the `ClientGateway` in your code, there are two ways to do so: + +### 1. Method Argument Injection + +Every refernce of a MCP element, that translates to an actual method call, can just add an type-hinted argument for the +`ClientGateway` and the SDK will take care to include the gateway in the arguments of the method call: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\ClientGateway; + +class MyService +{ + #[McpTool('my_tool', 'My Tool Description')] + public function myTool(ClientGateway $client): string + { + $client->log(...); +``` + +### 2. Implementing `ClientAwareInterface` + +Whenever a service class of an MCP element implements the interface `Mcp\Server\ClientAwareInterface` the `setClient` +method of that class will get called while handling the reference, and in combination with `Mcp\Server\ClientAwareTrait` +this ends up with code like this: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\ClientAwareInterface; +use Mcp\Server\ClientAwareTrait; + +class MyService implements ClientAwareInterface +{ + use ClientAwareTrait; + + #[McpTool('my_tool', 'My Tool Description')] + public function myTool(): string + { + $this->log(...); +``` + +## Sampling + +With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to +execute "completions" or "generations" with a language model for them: + +```php +$result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temperature' => 0.5]); +``` + +The `sample` method accepts four arguments: + +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SampleMessage` instances. +2. `maxTokens`, which defaults to `1000` +3. `timeout` in seconds, which defaults to `120` +4. `options` which might include `system_prompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` + +[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) + +## Logging + +The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers +to send structured log messages as notifcation to clients: + +```php +use Mcp\Schema\Enum\LoggingLevel; + +$clientGateway->log(LoggingLevel::Warning, 'The end is near.'); +``` + +## Progress + +With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress) +notification a server can update a client while an operation is ongoing: + +```php +$clientGateway->progress(4.2, 10, 'Downloading needed images.'); +``` + +## Notification + +Lastly, the server can push all kind of notifications, that implement the `Mcp\Schema\JsonRpc\Notification` interface +to the client to: + +```php +$clientGateway->notify($yourNotification); +``` diff --git a/docs/examples.md b/docs/examples.md index 77fdc1f1..a242b9a8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -163,6 +163,16 @@ $server = Server::builder() ->setDiscovery(__DIR__, ['.'], [], $cache) ``` +### Client Communication + +**File**: `examples/stdio-client-communication/` + +**What it demostrates:** +- Server initiated communcation back to the client +- Logging, sampling, progress and notifications +- Using `ClientGateway` in service class via `ClientAwareInterface` and corresponding trait +- Using `ClientGateway` in tool method via method argument injection + ## HTTP Examples ### Discovery User Profile diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php index 8191a8e2..12f18eec 100644 --- a/examples/http-client-communication/server.php +++ b/examples/http-client-communication/server.php @@ -14,10 +14,8 @@ use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Exception\ToolCallException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; -use Mcp\Schema\JsonRpc\Error as JsonRpcError; use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\ClientGateway; @@ -57,18 +55,13 @@ function (string $projectName, array $milestones, ClientGateway $client): array implode(', ', $milestones) ); - $response = $client->sample( - prompt: $prompt, + $result = $client->sample( + message: $prompt, maxTokens: 400, timeout: 90, options: ['temperature' => 0.4] ); - if ($response instanceof JsonRpcError) { - throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); - } - - $result = $response->result; $content = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; $client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.'); diff --git a/examples/stdio-client-communication/ClientAwareService.php b/examples/stdio-client-communication/ClientAwareService.php new file mode 100644 index 00000000..ebb797aa --- /dev/null +++ b/examples/stdio-client-communication/ClientAwareService.php @@ -0,0 +1,71 @@ +logger->info('SamplingTool instantiated for sampling example.'); + } + + /** + * @return array{incident: string, recommended_actions: string, model: string} + */ + #[McpTool('coordinate_incident_response', 'Coordinate an incident response with logging, progress, and sampling.')] + public function coordinateIncident(string $incidentTitle): array + { + $this->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); + + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; + + foreach ($steps as $index => $step) { + $progress = ($index + 1) / \count($steps); + + $this->progress($progress, 1, $step); + + usleep(180_000); // Simulate work being done + } + + $prompt = \sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + $result = $this->sample($prompt, 350, 90, ['temperature' => 0.5]); + + $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; + + $this->log(LoggingLevel::Info, \sprintf('Incident triage completed for %s', $incidentTitle)); + + return [ + 'incident' => $incidentTitle, + 'recommended_actions' => $recommendation, + 'model' => $result->model, + ]; + } +} diff --git a/examples/stdio-client-communication/server.php b/examples/stdio-client-communication/server.php index 96d2f052..596da5ad 100644 --- a/examples/stdio-client-communication/server.php +++ b/examples/stdio-client-communication/server.php @@ -13,70 +13,18 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; -use Mcp\Schema\JsonRpc\Error as JsonRpcError; use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\ClientGateway; use Mcp\Server\Transport\StdioTransport; -$capabilities = new ServerCapabilities(logging: true, tools: true); - $server = Server::builder() ->setServerInfo('STDIO Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) - ->setCapabilities($capabilities) - ->addTool( - function (string $incidentTitle, ClientGateway $client): array { - $client->log(LoggingLevel::Warning, sprintf('Incident triage started: %s', $incidentTitle)); - - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / count($steps); - - $client->progress(progress: $progress, total: 1, message: $step); - - usleep(180_000); // Simulate work being done - } - - $prompt = sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); - - $sampling = $client->sample( - prompt: $prompt, - maxTokens: 350, - timeout: 90, - options: ['temperature' => 0.5] - ); - - if ($sampling instanceof JsonRpcError) { - throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $sampling->code, $sampling->message)); - } - - $result = $sampling->result; - $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; - - $client->log(LoggingLevel::Info, sprintf('Incident triage completed for %s', $incidentTitle)); - - return [ - 'incident' => $incidentTitle, - 'recommended_actions' => $recommendation, - 'model' => $result->model, - ]; - }, - name: 'coordinate_incident_response', - description: 'Coordinate an incident response with logging, progress, and sampling.' - ) + ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) + ->setDiscovery(__DIR__) ->addTool( function (string $dataset, ClientGateway $client): array { $client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset)); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index e3eb925f..7ce8c737 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,7 +13,9 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; +use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** @@ -31,12 +33,18 @@ public function __construct( */ public function handle(ElementReference $reference, array $arguments): mixed { + $session = $arguments['_session']; + if (\is_string($reference->handler)) { if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { $reflection = new \ReflectionMethod($reference->handler, '__invoke'); $instance = $this->getClassInstance($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + return \call_user_func($instance, ...$arguments); } @@ -49,7 +57,7 @@ public function handle(ElementReference $reference, array $arguments): mixed } if (\is_callable($reference->handler)) { - $reflection = $this->getReflectionForCallable($reference->handler); + $reflection = $this->getReflectionForCallable($reference->handler, $session); $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func($reference->handler, ...$arguments); @@ -59,6 +67,11 @@ public function handle(ElementReference $reference, array $arguments): mixed [$className, $methodName] = $reference->handler; $reflection = new \ReflectionMethod($className, $methodName); $instance = $this->getClassInstance($className); + + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func([$instance, $methodName], ...$arguments); @@ -130,7 +143,7 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ - private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction + private function getReflectionForCallable(callable $handler, SessionInterface $session): \ReflectionMethod|\ReflectionFunction { if (\is_string($handler)) { return new \ReflectionFunction($handler); @@ -143,6 +156,10 @@ private function getReflectionForCallable(callable $handler): \ReflectionMethod| if (\is_array($handler) && 2 === \count($handler)) { [$class, $method] = $handler; + if ($class instanceof ClientAwareInterface) { + $class->setClient(new ClientGateway($session)); + } + return new \ReflectionMethod($class, $method); } diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php new file mode 100644 index 00000000..f77394ff --- /dev/null +++ b/src/Exception/ClientException.php @@ -0,0 +1,33 @@ + + */ +class ClientException extends Exception +{ + public function __construct( + private readonly Error $error, + ) { + parent::__construct($error->message); + } + + public function getError(): Error + { + return $this->error; + } +} diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index b34835dd..48aaa713 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -18,7 +18,7 @@ * Describes a message issued to or received from an LLM API during sampling. * * @phpstan-type SamplingMessageData = array{ - * role: string, + * role: 'user'|'assistant', * content: TextContent|ImageContent|AudioContent * } * diff --git a/src/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php new file mode 100644 index 00000000..4c1f1851 --- /dev/null +++ b/src/Schema/Enum/SamplingContext.php @@ -0,0 +1,19 @@ + $metadata Optional metadata to pass through to the LLM provider. The format of - * this metadata is provider-specific. + * Allowed values: "none", "thisServer", "allServers" + * @param ?float $temperature The temperature to use for sampling. The client MAY ignore this request. + * @param ?string[] $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. + * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of + * this metadata is provider-specific. */ public function __construct( public readonly array $messages, public readonly int $maxTokens, public readonly ?ModelPreferences $preferences = null, public readonly ?string $systemPrompt = null, - public readonly ?string $includeContext = null, + public readonly ?SamplingContext $includeContext = null, public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, ) { + foreach ($this->messages as $message) { + if (!$message instanceof SamplingMessage) { + throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); + } + } } public static function getMethod(): string @@ -114,7 +119,7 @@ protected function getParams(): array } if (null !== $this->includeContext) { - $params['includeContext'] = $this->includeContext; + $params['includeContext'] = $this->includeContext->value; } if (null !== $this->temperature) { diff --git a/src/Server/ClientAwareInterface.php b/src/Server/ClientAwareInterface.php new file mode 100644 index 00000000..86c8c2ef --- /dev/null +++ b/src/Server/ClientAwareInterface.php @@ -0,0 +1,17 @@ +client = $client; + } + + private function notify(Notification $notification): void + { + $this->client->notify($notification); + } + + private function log(LoggingLevel $level, mixed $data, ?string $logger = null): void + { + $this->client->log($level, $data, $logger); + } + + private function progress(float $progress, ?float $total = null, ?string $message = null): void + { + $this->client->progress($progress, $total, $message); + } + + /** + * @param SampleOptions $options + */ + private function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult + { + return $this->client->sample($prompt, $maxTokens, $timeout, $options); + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 7e95fe9a..8179aee2 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -11,10 +11,17 @@ namespace Mcp\Server; +use Mcp\Exception\ClientException; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\RuntimeException; +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -46,6 +53,15 @@ * } * ``` * + * @phpstan-type SampleOptions array{ + * preferences?: ModelPreferences, + * systemPrompt?: string, + * temperature?: float, + * includeContext?: SamplingContext, + * stopSequences?: string[], + * metadata?: array, + * } + * * @author Kyrian Obikwelu */ final class ClientGateway @@ -95,89 +111,77 @@ public function progress(float $progress, ?float $total = null, ?string $message } /** - * Send a request to the client and wait for a response (blocking). + * Convenience method for LLM sampling requests. * - * This suspends the Fiber and waits for the client to respond. The transport - * handles polling the session for the response and resuming the Fiber when ready. + * @param SamplingMessage[]|TextContent|AudioContent|ImageContent|string $message The message for the LLM + * @param int $maxTokens Maximum tokens to generate + * @param int $timeout The timeout in seconds + * @param SampleOptions $options Additional sampling options (temperature, etc.) * - * @param Request $request The request to send - * @param int $timeout Maximum time to wait for response (seconds) + * @return CreateSamplingMessageResult The sampling response * - * @return Response>|Error The client's response message - * - * @throws \RuntimeException If Fiber support is not available + * @throws ClientException if the client request results in an error message */ - public function request(Request $request, int $timeout = 120): Response|Error + public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult { - $response = \Fiber::suspend([ - 'type' => 'request', - 'request' => $request, - 'session_id' => $this->session->getId()->toRfc4122(), - 'timeout' => $timeout, - ]); + $preferences = $options['preferences'] ?? null; + if (null !== $preferences && !$preferences instanceof ModelPreferences) { + throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + } - if (!$response instanceof Response && !$response instanceof Error) { - throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); + if (\is_string($message)) { + $message = new TextContent($message); + } + if (\is_object($message) && \in_array($message::class, [TextContent::class, AudioContent::class, ImageContent::class], true)) { + $message = [new SamplingMessage(Role::User, $message)]; } - return $response; - } + $request = new CreateSamplingMessageRequest( + messages: $message, + maxTokens: $maxTokens, + preferences: $preferences, + systemPrompt: $options['systemPrompt'] ?? null, + includeContext: $options['includeContext'] ?? null, + temperature: $options['temperature'] ?? null, + stopSequences: $options['stopSequences'] ?? null, + metadata: $options['metadata'] ?? null, + ); - /** - * Create and send an LLM sampling requests. - * - * @param CreateSamplingMessageRequest $request The request to send - * @param int $timeout The timeout in seconds - * - * @return Response|Error The sampling response - */ - public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error - { $response = $this->request($request, $timeout); if ($response instanceof Error) { - return $response; + throw new ClientException($response); } - $result = CreateSamplingMessageResult::fromArray($response->result); - - return new Response($response->getId(), $result); + return CreateSamplingMessageResult::fromArray($response->result); } /** - * Convenience method for LLM sampling requests. + * Send a request to the client and wait for a response (blocking). * - * @param string $prompt The prompt for the LLM - * @param int $maxTokens Maximum tokens to generate - * @param int $timeout The timeout in seconds - * @param array $options Additional sampling options (temperature, etc.) + * This suspends the Fiber and waits for the client to respond. The transport + * handles polling the session for the response and resuming the Fiber when ready. * - * @return Response|Error The sampling response + * @param Request $request The request to send + * @param int $timeout Maximum time to wait for response (seconds) + * + * @return Response>|Error The client's response message + * + * @throws RuntimeException If Fiber support is not available */ - public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error + private function request(Request $request, int $timeout = 120): Response|Error { - $preferences = $options['preferences'] ?? null; - if (\is_array($preferences)) { - $preferences = ModelPreferences::fromArray($preferences); - } + $response = \Fiber::suspend([ + 'type' => 'request', + 'request' => $request, + 'session_id' => $this->session->getId()->toRfc4122(), + 'timeout' => $timeout, + ]); - if (null !== $preferences && !$preferences instanceof ModelPreferences) { - throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + if (!$response instanceof Response && !$response instanceof Error) { + throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); } - $samplingRequest = new CreateSamplingMessageRequest( - messages: [ - new SamplingMessage(Role::User, new TextContent(text: $prompt)), - ], - maxTokens: $maxTokens, - preferences: $preferences, - systemPrompt: $options['systemPrompt'] ?? null, - includeContext: $options['includeContext'] ?? null, - temperature: $options['temperature'] ?? null, - stopSequences: $options['stopSequences'] ?? null, - metadata: $options['metadata'] ?? null, - ); - - return $this->createMessage($samplingRequest, $timeout); + return $response; } } diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php new file mode 100644 index 00000000..77ea7945 --- /dev/null +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -0,0 +1,51 @@ +assertCount(3, $request->messages); + $this->assertSame(150, $request->maxTokens); + } + + public function testConstructorWithInvalidSetOfMessages() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Messages must be instance of SamplingMessage.'); + + $messages = [ + new SamplingMessage(Role::User, new TextContent('My name is George.')), + new SamplingMessage(Role::Assistant, new TextContent('Hi George, nice to meet you!')), + new TextContent('What is my name?'), + ]; + + /* @phpstan-ignore argument.type */ + new CreateSamplingMessageRequest($messages, 150); + } +}