[Schema][Server] feat: support sampling with tools (#409)

* feat: support sampling with tools

* address sampling tools review feedback

* tighten sampling tools spec compliance

* drop SamplingStopReason enum in favor of open string

The spec leaves stopReason open for provider-specific values, so upcasting
the four known ones to enum cases makes every future enum addition a silent
BC break for string comparisons.

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
wWzZb 2026-08-15 09:28:11 +08:00 • committed by GitHub
commit 82aac972c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 682 additions and 27 deletions

View file

@ -30,6 +30,8 @@ class ClientCapabilities implements \JsonSerializable
public readonly ?bool $elicitation = null,
public readonly ?array $experimental = null,
public readonly ?array $extensions = null,
public readonly ?bool $samplingContext = null,
public readonly ?bool $samplingTools = null,
) {
}
@ -38,7 +40,7 @@ class ClientCapabilities implements \JsonSerializable
* roots?: array{
* listChanged?: bool,
* },
* sampling?: bool,
* sampling?: array{context?: mixed, tools?: mixed}|object,
* elicitation?: bool,
* experimental?: array<string, mixed>,
* extensions?: array<string, mixed>,
@ -57,8 +59,17 @@ class ClientCapabilities implements \JsonSerializable
}
$sampling = null;
$samplingContext = null;
$samplingTools = null;
if (isset($data['sampling'])) {
$sampling = true;
if (\is_array($data['sampling'])) {
$samplingContext = isset($data['sampling']['context']);
$samplingTools = isset($data['sampling']['tools']);
} elseif (\is_object($data['sampling'])) {
$samplingContext = property_exists($data['sampling'], 'context');
$samplingTools = property_exists($data['sampling'], 'tools');
}
}
$elicitation = null;
@ -73,6 +84,8 @@ class ClientCapabilities implements \JsonSerializable
$elicitation,
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
$samplingContext,
$samplingTools,
);
}
@ -95,8 +108,14 @@ class ClientCapabilities implements \JsonSerializable
}
}
if ($this->sampling) {
if ($this->sampling || $this->samplingContext || $this->samplingTools) {
$data['sampling'] = new \stdClass();
if ($this->samplingContext) {
$data['sampling']->context = new \stdClass();
}
if ($this->samplingTools) {
$data['sampling']->tools = new \stdClass();
}
}
if ($this->elicitation) {

View file

@ -17,19 +17,44 @@ use Mcp\Schema\Enum\Role;
/**
* Describes a message issued to or received from an LLM API during sampling.
*
* @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent
* @phpstan-type SamplingMessageData = array{
* role: 'user'|'assistant',
* content: TextContent|ImageContent|AudioContent
* content: array<string, mixed>|array<array<string, mixed>>,
* _meta?: array<string, mixed>
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SamplingMessage extends Content
{
/**
* @param SamplingContent|list<SamplingContent> $content
* @param ?array<string, mixed> $meta
*/
public function __construct(
public readonly Role $role,
public readonly TextContent|ImageContent|AudioContent $content,
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content,
public readonly ?array $meta = null,
) {
$contents = \is_array($content) ? $content : [$content];
foreach ($contents as $item) {
if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent && !$item instanceof ToolResultContent) {
throw new InvalidArgumentException('Sampling message content contains an unsupported content block.');
}
if (Role::User === $role && $item instanceof ToolUseContent) {
throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.');
}
if (Role::Assistant === $role && $item instanceof ToolResultContent) {
throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.');
}
}
if (array_filter($contents, static fn ($item): bool => $item instanceof ToolResultContent)
&& array_filter($contents, static fn ($item): bool => !$item instanceof ToolResultContent)) {
throw new InvalidArgumentException('Tool result messages must not contain other content types.');
}
parent::__construct('sampling');
}
@ -51,18 +76,26 @@ class SamplingMessage extends Content
$contentData = $data['content'];
$contentType = $contentData['type'] ?? null;
if (!\is_string($contentType)) {
if (null !== $contentType && !\is_string($contentType)) {
throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.');
}
$contentInstance = match ($contentType) {
'text' => TextContent::fromArray($contentData),
'image' => ImageContent::fromArray($contentData),
'audio' => AudioContent::fromArray($contentData),
default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)),
};
$isSingleContent = null !== $contentType;
$contentItems = $isSingleContent ? [$contentData] : $contentData;
$content = [];
return new self($role, $contentInstance);
foreach ($contentItems as $item) {
if (!\is_array($item)) {
throw new InvalidArgumentException('Invalid content block in SamplingMessage data.');
}
$content[] = self::hydrateContent($item);
}
return new self(
$role,
$isSingleContent ? $content[0] : $content,
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
);
}
/**
@ -70,9 +103,34 @@ class SamplingMessage extends Content
*/
public function jsonSerialize(): array
{
return [
$data = [
'role' => $this->role->value,
'content' => $this->content,
];
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}
return $data;
}
/**
* @param array<string, mixed> $contentData
*
* @return SamplingContent
*/
private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent
{
$contentType = $contentData['type'] ?? null;
return match ($contentType) {
'text' => TextContent::fromArray($contentData),
'image' => ImageContent::fromArray($contentData),
'audio' => AudioContent::fromArray($contentData),
'tool_use' => ToolUseContent::fromArray($contentData),
'tool_result' => ToolResultContent::fromArray($contentData),
default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)),
};
}
}

View file

@ -0,0 +1,99 @@
<?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\Content;
use Mcp\Exception\InvalidArgumentException;
/**
* The result of a tool use, provided by the user back to the assistant.
*/
final class ToolResultContent extends Content
{
/**
* @param Content[] $content
* @param ?array<string, mixed> $structuredContent
* @param ?array<string, mixed> $meta
*/
public function __construct(
public readonly string $toolUseId,
public readonly array $content,
public readonly ?array $structuredContent = null,
public readonly bool $isError = false,
public readonly ?array $meta = null,
) {
foreach ($content as $item) {
if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof EmbeddedResource) {
throw new InvalidArgumentException('Tool result content must contain standard content blocks.');
}
}
parent::__construct('tool_result');
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) {
throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.');
}
if (!isset($data['content']) || !\is_array($data['content'])) {
throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.');
}
$content = [];
foreach ($data['content'] as $item) {
if (!\is_array($item)) {
throw new InvalidArgumentException('Invalid content block in ToolResultContent data.');
}
$content[] = match ($item['type'] ?? null) {
'text' => TextContent::fromArray($item),
'image' => ImageContent::fromArray($item),
'audio' => AudioContent::fromArray($item),
'resource' => EmbeddedResource::fromArray($item),
default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)),
};
}
return new self(
$data['toolUseId'],
$content,
isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null,
isset($data['isError']) && true === $data['isError'],
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'type' => $this->type,
'toolUseId' => $this->toolUseId,
'content' => $this->content,
'isError' => $this->isError,
];
if (null !== $this->structuredContent) {
$data['structuredContent'] = $this->structuredContent;
}
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}
return $data;
}
}

View file

@ -0,0 +1,75 @@
<?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\Content;
use Mcp\Exception\InvalidArgumentException;
/**
* A request from the assistant to call a tool.
*/
final class ToolUseContent extends Content
{
/**
* @param array<string, mixed> $input
* @param ?array<string, mixed> $meta
*/
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly array $input,
public readonly ?array $meta = null,
) {
parent::__construct('tool_use');
}
/**
* @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data
*/
public static function fromArray(array $data): self
{
if (!isset($data['id']) || !\is_string($data['id'])) {
throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.');
}
if (!isset($data['name']) || !\is_string($data['name'])) {
throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.');
}
if (!isset($data['input']) || !\is_array($data['input'])) {
throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.');
}
return new self(
$data['id'],
$data['name'],
$data['input'],
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
);
}
/**
* @return array{type: 'tool_use', id: string, name: string, input: array<string, mixed>|\stdClass, _meta?: array<string, mixed>}
*/
public function jsonSerialize(): array
{
$data = [
'type' => $this->type,
'id' => $this->id,
'name' => $this->name,
'input' => $this->input ?: new \stdClass(),
];
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}
return $data;
}
}

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 ToolChoiceMode: string
{
case Auto = 'auto';
case Required = 'required';
case None = 'none';
}

View file

@ -16,6 +16,8 @@ use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Enum\SamplingContext;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\ModelPreferences;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolChoice;
/**
* A request from the server to sample an LLM via the client. The client has full discretion over which model to select.
@ -37,10 +39,14 @@ final class CreateSamplingMessageRequest extends Request
* @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"
* Values other than "none" are soft-deprecated and SHOULD only be sent
* when the client advertises the sampling.context capability.
* @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.
* @param ?Tool[] $tools tools that the model may use during generation
* @param ?ToolChoice $toolChoice controls how the model uses tools
*/
public function __construct(
public readonly array $messages,
@ -51,12 +57,19 @@ final class CreateSamplingMessageRequest extends Request
public readonly ?float $temperature = null,
public readonly ?array $stopSequences = null,
public readonly ?array $metadata = null,
public readonly ?array $tools = null,
public readonly ?ToolChoice $toolChoice = null,
) {
foreach ($this->messages as $message) {
if (!$message instanceof SamplingMessage) {
throw new InvalidArgumentException('Messages must be instance of SamplingMessage.');
}
}
foreach ($this->tools ?? [] as $tool) {
if (!$tool instanceof Tool) {
throw new InvalidArgumentException('Tools must be instances of Tool.');
}
}
}
public static function getMethod(): string
@ -122,6 +135,34 @@ final class CreateSamplingMessageRequest extends Request
throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.');
}
$tools = null;
if (isset($params['tools'])) {
if (!\is_array($params['tools'])) {
throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.');
}
$tools = [];
foreach ($params['tools'] as $toolData) {
if ($toolData instanceof Tool) {
$tools[] = $toolData;
} elseif (\is_array($toolData)) {
$tools[] = Tool::fromArray($toolData);
} else {
throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.');
}
}
}
$toolChoice = null;
if (isset($params['toolChoice'])) {
if ($params['toolChoice'] instanceof ToolChoice) {
$toolChoice = $params['toolChoice'];
} elseif (\is_array($params['toolChoice'])) {
$toolChoice = ToolChoice::fromArray($params['toolChoice']);
} else {
throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.');
}
}
return new self(
$messages,
$params['maxTokens'],
@ -131,6 +172,8 @@ final class CreateSamplingMessageRequest extends Request
isset($params['temperature']) ? (float) $params['temperature'] : null,
$params['stopSequences'] ?? null,
$params['metadata'] ?? null,
$tools,
$toolChoice,
);
}
@ -143,7 +186,9 @@ final class CreateSamplingMessageRequest extends Request
* includeContext?: string,
* temperature?: float,
* stopSequences?: string[],
* metadata?: array<string, mixed>
* metadata?: array<string, mixed>,
* tools?: Tool[],
* toolChoice?: ToolChoice,
* }
*/
protected function getParams(): array
@ -177,6 +222,14 @@ final class CreateSamplingMessageRequest extends Request
$params['metadata'] = $this->metadata;
}
if (null !== $this->tools) {
$params['tools'] = $this->tools;
}
if (null !== $this->toolChoice) {
$params['toolChoice'] = $this->toolChoice;
}
return $params;
}
}

View file

@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\ToolUseContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\JsonRpc\ResultInterface;
@ -28,17 +29,30 @@ use Mcp\Schema\JsonRpc\ResultInterface;
class CreateSamplingMessageResult implements ResultInterface
{
/**
* @param Role $role the role of the message
* @param TextContent|ImageContent|AudioContent $content the content of the message
* @param string $model the name of the model that generated the message
* @param string|null $stopReason the reason why sampling stopped, if known
* @param Role $role the role of the message
* @param TextContent|ImageContent|AudioContent|ToolUseContent|list<TextContent|ImageContent|AudioContent|ToolUseContent> $content the content of the message
* @param string $model the name of the model that generated the message
* @param ?string $stopReason The reason why sampling stopped, if known. The spec defines "endTurn",
* "stopSequence", "maxTokens" and "toolUse", but leaves the set open for
* provider-specific values, so this stays an unconstrained string.
* @param ?array<string, mixed> $meta optional message metadata
*/
public function __construct(
public readonly Role $role,
public readonly TextContent|ImageContent|AudioContent $content,
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content,
public readonly string $model,
public readonly ?string $stopReason = null,
public readonly ?array $meta = null,
) {
if (Role::Assistant !== $role) {
throw new InvalidArgumentException('CreateSamplingMessageResult role must be "assistant".');
}
foreach (\is_array($content) ? $content : [$content] as $item) {
if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ToolUseContent) {
throw new InvalidArgumentException('CreateSamplingMessageResult contains an unsupported content block.');
}
}
}
/**
@ -64,16 +78,31 @@ class CreateSamplingMessageResult implements ResultInterface
$contentPayload = $data['content'];
$content = self::hydrateContent($contentPayload);
$isSingleContent = isset($contentPayload['type']);
$contentItems = $isSingleContent ? [$contentPayload] : $contentPayload;
$content = [];
foreach ($contentItems as $item) {
if (!\is_array($item)) {
throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.');
}
$content[] = self::hydrateContent($item);
}
$stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null;
return new self($role, $content, $data['model'], $stopReason);
return new self(
$role,
$isSingleContent ? $content[0] : $content,
$data['model'],
$stopReason,
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
);
}
/**
* @param array<string, mixed> $contentData
*/
private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent
private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent
{
$type = $contentData['type'] ?? null;
@ -85,6 +114,7 @@ class CreateSamplingMessageResult implements ResultInterface
'text' => TextContent::fromArray($contentData),
'image' => ImageContent::fromArray($contentData),
'audio' => AudioContent::fromArray($contentData),
'tool_use' => ToolUseContent::fromArray($contentData),
default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)),
};
}
@ -92,9 +122,10 @@ class CreateSamplingMessageResult implements ResultInterface
/**
* @return array{
* role: string,
* content: TextContent|ImageContent|AudioContent,
* content: TextContent|ImageContent|AudioContent|ToolUseContent|list<TextContent|ImageContent|AudioContent|ToolUseContent>,
* model: string,
* stopReason?: string,
* _meta?: array<string, mixed>,
* }
*/
public function jsonSerialize(): array
@ -109,6 +140,10 @@ class CreateSamplingMessageResult implements ResultInterface
$result['stopReason'] = $this->stopReason;
}
if (null !== $this->meta) {
$result['_meta'] = $this->meta;
}
return $result;
}
}

51
src/Schema/ToolChoice.php Normal file
View file

@ -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\Schema;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\ToolChoiceMode;
/**
* Controls how the model uses tools during sampling.
*/
final class ToolChoice implements \JsonSerializable
{
public function __construct(
public readonly ToolChoiceMode $mode = ToolChoiceMode::Auto,
) {
}
/**
* @param array{mode?: string} $data
*/
public static function fromArray(array $data): self
{
if (isset($data['mode']) && !\is_string($data['mode'])) {
throw new InvalidArgumentException('Invalid "mode" in ToolChoice data.');
}
$mode = isset($data['mode']) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto;
if (null === $mode) {
throw new InvalidArgumentException(\sprintf('Invalid tool choice mode "%s".', $data['mode']));
}
return new self($mode);
}
/**
* @return array{mode: string}
*/
public function jsonSerialize(): array
{
return ['mode' => $this->mode->value];
}
}

View file

@ -36,6 +36,8 @@ use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\ListRootsResult;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolChoice;
use Mcp\Server\Session\SessionInterface;
/**
@ -67,6 +69,8 @@ use Mcp\Server\Session\SessionInterface;
* includeContext?: SamplingContext,
* stopSequences?: string[],
* metadata?: array<string, mixed>,
* tools?: Tool[],
* toolChoice?: ToolChoice,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
@ -124,6 +128,9 @@ class ClientGateway
* @param int $maxTokens Maximum tokens to generate
* @param int $timeout The timeout in seconds
* @param SampleOptions $options Additional sampling options (temperature, etc.)
* Context values other than `none` require the client's
* sampling.context capability; tools and toolChoice require
* the client's sampling.tools capability.
*
* @return CreateSamplingMessageResult The sampling response
*
@ -152,6 +159,8 @@ class ClientGateway
temperature: $options['temperature'] ?? null,
stopSequences: $options['stopSequences'] ?? null,
metadata: $options['metadata'] ?? null,
tools: $options['tools'] ?? null,
toolChoice: $options['toolChoice'] ?? null,
);
$response = $this->request($request, $timeout);