Enable servers to send sampling messages to clients (#101)
This commit is contained in:
parent
67cc641247
commit
fb3c1e65a6
16 changed files with 460 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
101
docs/client-communication.md
Normal file
101
docs/client-communication.md
Normal file
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ chdir(__DIR__);
|
|||
|
||||
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 @@ $server = Server::builder()
|
|||
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.');
|
||||
|
|
|
|||
71
examples/stdio-client-communication/ClientAwareService.php
Normal file
71
examples/stdio-client-communication/ClientAwareService.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Example\StdioClientCommunication;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Server\ClientAwareInterface;
|
||||
use Mcp\Server\ClientAwareTrait;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class ClientAwareService implements ClientAwareInterface
|
||||
{
|
||||
use ClientAwareTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ namespace Mcp\Capability\Registry;
|
|||
|
||||
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 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
*/
|
||||
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 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
}
|
||||
|
||||
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 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
[$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 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
/**
|
||||
* 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 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
|||
if (\is_array($handler) && 2 === \count($handler)) {
|
||||
[$class, $method] = $handler;
|
||||
|
||||
if ($class instanceof ClientAwareInterface) {
|
||||
$class->setClient(new ClientGateway($session));
|
||||
}
|
||||
|
||||
return new \ReflectionMethod($class, $method);
|
||||
}
|
||||
|
||||
|
|
|
|||
33
src/Exception/ClientException.php
Normal file
33
src/Exception/ClientException.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
|
||||
/**
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class ClientException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Error $error,
|
||||
) {
|
||||
parent::__construct($error->message);
|
||||
}
|
||||
|
||||
public function getError(): Error
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ use Mcp\Schema\Enum\Role;
|
|||
* 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
|
||||
* }
|
||||
*
|
||||
|
|
|
|||
19
src/Schema/Enum/SamplingContext.php
Normal file
19
src/Schema/Enum/SamplingContext.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Schema\Enum;
|
||||
|
||||
enum SamplingContext: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case THIS_SERVER = 'thisServer';
|
||||
case ALL_SERVERS = 'allServers';
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Schema\Request;
|
|||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Content\SamplingMessage;
|
||||
use Mcp\Schema\Enum\SamplingContext;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\ModelPreferences;
|
||||
|
||||
|
|
@ -29,29 +30,33 @@ final class CreateSamplingMessageRequest extends Request
|
|||
* @param SamplingMessage[] $messages the messages to send to the model
|
||||
* @param int $maxTokens The maximum number of tokens to sample, as requested by the server.
|
||||
* The client MAY choose to sample fewer tokens than requested.
|
||||
* @param ModelPreferences|null $preferences The server's preferences for which model to select. The client MAY
|
||||
* @param ?ModelPreferences $preferences The server's preferences for which model to select. The client MAY
|
||||
* ignore these preferences.
|
||||
* @param string|null $systemPrompt An optional system prompt the server wants to use for sampling. The
|
||||
* @param ?string $systemPrompt An optional system prompt the server wants to use for sampling. The
|
||||
* client MAY modify or omit this prompt.
|
||||
* @param string|null $includeContext A request to include context from one or more MCP servers (including
|
||||
* @param ?SamplingContext $includeContext A request to include context from one or more MCP servers (including
|
||||
* the caller), to be attached to the prompt. The client MAY ignore this request.
|
||||
*
|
||||
* Allowed values: "none", "thisServer", "allServers"
|
||||
* @param float|null $temperature The temperature to use for sampling. The client MAY ignore this request.
|
||||
* @param string[]|null $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request.
|
||||
* @param ?array<string, mixed> $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<string, mixed> $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 @@ final class CreateSamplingMessageRequest extends Request
|
|||
}
|
||||
|
||||
if (null !== $this->includeContext) {
|
||||
$params['includeContext'] = $this->includeContext;
|
||||
$params['includeContext'] = $this->includeContext->value;
|
||||
}
|
||||
|
||||
if (null !== $this->temperature) {
|
||||
|
|
|
|||
17
src/Server/ClientAwareInterface.php
Normal file
17
src/Server/ClientAwareInterface.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server;
|
||||
|
||||
interface ClientAwareInterface
|
||||
{
|
||||
public function setClient(ClientGateway $clientGateway): void;
|
||||
}
|
||||
52
src/Server/ClientAwareTrait.php
Normal file
52
src/Server/ClientAwareTrait.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Server;
|
||||
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\JsonRpc\Notification;
|
||||
use Mcp\Schema\Result\CreateSamplingMessageResult;
|
||||
|
||||
/**
|
||||
* @phpstan-import-type SampleOptions from ClientGateway
|
||||
*/
|
||||
trait ClientAwareTrait
|
||||
{
|
||||
private ClientGateway $client;
|
||||
|
||||
public function setClient(ClientGateway $client): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 @@ use Mcp\Server\Session\SessionInterface;
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @phpstan-type SampleOptions array{
|
||||
* preferences?: ModelPreferences,
|
||||
* systemPrompt?: string,
|
||||
* temperature?: float,
|
||||
* includeContext?: SamplingContext,
|
||||
* stopSequences?: string[],
|
||||
* metadata?: array<string, mixed>,
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
final class ClientGateway
|
||||
|
|
@ -94,6 +110,52 @@ final class ClientGateway
|
|||
$this->notify(new ProgressNotification($progressToken, $progress, $total, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for LLM sampling requests.
|
||||
*
|
||||
* @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.)
|
||||
*
|
||||
* @return CreateSamplingMessageResult The sampling response
|
||||
*
|
||||
* @throws ClientException if the client request results in an error message
|
||||
*/
|
||||
public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult
|
||||
{
|
||||
$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 (\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)];
|
||||
}
|
||||
|
||||
$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,
|
||||
);
|
||||
|
||||
$response = $this->request($request, $timeout);
|
||||
|
||||
if ($response instanceof Error) {
|
||||
throw new ClientException($response);
|
||||
}
|
||||
|
||||
return CreateSamplingMessageResult::fromArray($response->result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the client and wait for a response (blocking).
|
||||
*
|
||||
|
|
@ -105,9 +167,9 @@ final class ClientGateway
|
|||
*
|
||||
* @return Response<array<string, mixed>>|Error The client's response message
|
||||
*
|
||||
* @throws \RuntimeException If Fiber support is not available
|
||||
* @throws RuntimeException If Fiber support is not available
|
||||
*/
|
||||
public function request(Request $request, int $timeout = 120): Response|Error
|
||||
private function request(Request $request, int $timeout = 120): Response|Error
|
||||
{
|
||||
$response = \Fiber::suspend([
|
||||
'type' => 'request',
|
||||
|
|
@ -117,67 +179,9 @@ final class ClientGateway
|
|||
]);
|
||||
|
||||
if (!$response instanceof Response && !$response instanceof Error) {
|
||||
throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
|
||||
throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and send an LLM sampling requests.
|
||||
*
|
||||
* @param CreateSamplingMessageRequest $request The request to send
|
||||
* @param int $timeout The timeout in seconds
|
||||
*
|
||||
* @return Response<CreateSamplingMessageResult>|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;
|
||||
}
|
||||
|
||||
$result = CreateSamplingMessageResult::fromArray($response->result);
|
||||
|
||||
return new Response($response->getId(), $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for LLM sampling requests.
|
||||
*
|
||||
* @param string $prompt The prompt for the LLM
|
||||
* @param int $maxTokens Maximum tokens to generate
|
||||
* @param int $timeout The timeout in seconds
|
||||
* @param array<string, mixed> $options Additional sampling options (temperature, etc.)
|
||||
*
|
||||
* @return Response<CreateSamplingMessageResult>|Error The sampling response
|
||||
*/
|
||||
public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error
|
||||
{
|
||||
$preferences = $options['preferences'] ?? null;
|
||||
if (\is_array($preferences)) {
|
||||
$preferences = ModelPreferences::fromArray($preferences);
|
||||
}
|
||||
|
||||
if (null !== $preferences && !$preferences instanceof ModelPreferences) {
|
||||
throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.');
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Schema\Request;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Content\SamplingMessage;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\Role;
|
||||
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CreateSamplingMessageRequestTest extends TestCase
|
||||
{
|
||||
public function testConstructorWithValidSetOfMessages()
|
||||
{
|
||||
$messages = [
|
||||
new SamplingMessage(Role::User, new TextContent('My name is George.')),
|
||||
new SamplingMessage(Role::Assistant, new TextContent('Hi George, nice to meet you!')),
|
||||
new SamplingMessage(Role::User, new TextContent('What is my name?')),
|
||||
];
|
||||
|
||||
$request = new CreateSamplingMessageRequest($messages, 150);
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue