Enable servers to send sampling messages to clients (#101)

This commit is contained in:
Christopher Hertel 2025-11-01 09:02:24 +01:00 • committed by GitHub
commit fb3c1e65a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 460 additions and 137 deletions

View file

@ -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);
}

View 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;
}
}

View file

@ -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
* }
*

View 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';
}

View file

@ -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) {

View 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;
}

View 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);
}
}

View file

@ -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);
}
}