[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
16 changed files with 682 additions and 27 deletions

View file

@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`.
* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`.
* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set.
* Add sampling with tools support: sampling requests now accept tools and tool-choice preferences, messages support tool-use/tool-result content blocks and multiple content blocks, and clients can advertise the `sampling.context` and `sampling.tools` capabilities.
0.7.0
-----

View file

@ -551,6 +551,42 @@ $client = Client::builder()
->build();
```
#### Sampling with Tools
Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and
`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more
`ToolUseContent` blocks:
```php
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\ToolUseContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Result\CreateSamplingMessageResult;
$client = Client::builder()
->setCapabilities(new ClientCapabilities(
sampling: true,
samplingContext: true,
samplingTools: true,
))
->addRequestHandler(new SamplingRequestHandler($samplingCallback))
->build();
// Inside the sampling callback, after invoking the LLM provider:
return new CreateSamplingMessageResult(
role: Role::Assistant,
content: array_map(
static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input),
$providerResponse->toolCalls,
),
model: $providerResponse->model,
stopReason: 'toolUse',
);
```
The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent`
blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop.
> [!IMPORTANT]
> **Error Handling in Sampling Callbacks:**
>

View file

@ -32,7 +32,7 @@ class MyService
## Sampling
With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to
With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to
execute "completions" or "generations" with a language model for them:
```php
@ -41,12 +41,16 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper
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.
1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` 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`
4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`,
`stopSequences`, `metadata`, `tools`, and `toolChoice`
[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages)
Only send `includeContext` when the client advertises `sampling.context`, and only send `tools` or `toolChoice` when it
advertises `sampling.tools`. The context modes other than `none` are soft-deprecated by the current specification.
[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages)
## Logging

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

View file

@ -0,0 +1,45 @@
<?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;
use Mcp\Schema\ClientCapabilities;
use PHPUnit\Framework\TestCase;
final class ClientCapabilitiesSamplingTest extends TestCase
{
public function testSamplingSubCapabilitiesRoundTrip(): void
{
$capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true);
$serialized = $capabilities->jsonSerialize();
$this->assertObjectHasProperty('context', $serialized['sampling']);
$this->assertObjectHasProperty('tools', $serialized['sampling']);
$hydrated = ClientCapabilities::fromArray(json_decode(json_encode($serialized, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR));
$this->assertTrue($hydrated->sampling);
$this->assertTrue($hydrated->samplingContext);
$this->assertTrue($hydrated->samplingTools);
}
public function testSamplingSubCapabilitiesAreHydratedFromObject(): void
{
$sampling = new \stdClass();
$sampling->context = new \stdClass();
$sampling->tools = new \stdClass();
$capabilities = ClientCapabilities::fromArray(['sampling' => $sampling]);
$this->assertTrue($capabilities->sampling);
$this->assertTrue($capabilities->samplingContext);
$this->assertTrue($capabilities->samplingTools);
}
}

View file

@ -0,0 +1,76 @@
<?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\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\ToolResultContent;
use Mcp\Schema\Content\ToolUseContent;
use Mcp\Schema\Enum\Role;
use PHPUnit\Framework\TestCase;
final class SamplingToolContentTest extends TestCase
{
public function testToolLoopMessagesRoundTrip(): void
{
$assistant = SamplingMessage::fromArray([
'role' => 'assistant',
'_meta' => ['provider' => 'test'],
'content' => [
['type' => 'text', 'text' => 'I will check.'],
['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']],
],
]);
$user = SamplingMessage::fromArray([
'role' => 'user',
'content' => [[
'type' => 'tool_result',
'toolUseId' => 'call-1',
'content' => [['type' => 'text', 'text' => '21 C']],
'structuredContent' => ['temperature' => 21],
]],
]);
$this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]);
$this->assertSame(['provider' => 'test'], $assistant->meta);
$this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']);
$this->assertInstanceOf(ToolResultContent::class, $user->content[0]);
$this->assertSame(['temperature' => 21], $user->content[0]->structuredContent);
$textContent = $user->content[0]->content[0];
$this->assertInstanceOf(TextContent::class, $textContent);
$this->assertSame('21 C', $textContent->text);
}
public function testToolUseIsRejectedInUserMessage(): void
{
$this->expectException(InvalidArgumentException::class);
new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', []));
}
public function testToolResultCannotBeMixedWithOtherContent(): void
{
$this->expectException(InvalidArgumentException::class);
new SamplingMessage(Role::User, [
new ToolResultContent('call-1', [new TextContent('done')]),
new TextContent('extra'),
]);
}
public function testToolResultRejectsNonStandardContentBlocks(): void
{
$this->expectException(InvalidArgumentException::class);
new ToolResultContent('call-1', [
new SamplingMessage(Role::User, new TextContent('not a tool result content block')),
]);
}
}

View file

@ -15,7 +15,10 @@ use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Enum\ToolChoiceMode;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolChoice;
use PHPUnit\Framework\TestCase;
final class CreateSamplingMessageRequestTest extends TestCase
@ -48,4 +51,23 @@ final class CreateSamplingMessageRequestTest extends TestCase
/* @phpstan-ignore argument.type */
new CreateSamplingMessageRequest($messages, 150);
}
public function testToolsAndToolChoiceRoundTrip(): void
{
$tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null);
$request = new CreateSamplingMessageRequest(
[new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))],
150,
tools: [$tool],
toolChoice: new ToolChoice(ToolChoiceMode::Required),
);
$payload = $request->withId(1)->jsonSerialize();
$this->assertSame('weather', $payload['params']['tools'][0]->name);
$this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode);
$hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR));
$this->assertSame('weather', $hydrated->tools[0]->name);
$this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode);
}
}

View file

@ -0,0 +1,53 @@
<?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\Result;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\ToolUseContent;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use PHPUnit\Framework\TestCase;
final class CreateSamplingMessageResultTest extends TestCase
{
public function testArrayContentAndKnownStopReasonAreHydrated(): void
{
$result = CreateSamplingMessageResult::fromArray([
'role' => 'assistant',
'content' => [
['type' => 'text', 'text' => 'Checking weather.'],
['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']],
],
'model' => 'test-model',
'stopReason' => 'toolUse',
'_meta' => ['traceId' => 'trace-1'],
]);
$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertInstanceOf(ToolUseContent::class, $result->content[1]);
$this->assertSame('toolUse', $result->stopReason);
$this->assertSame('toolUse', $result->jsonSerialize()['stopReason']);
$this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']);
}
public function testProviderSpecificStopReasonIsPreserved(): void
{
$result = CreateSamplingMessageResult::fromArray([
'role' => 'assistant',
'content' => ['type' => 'text', 'text' => 'Done'],
'model' => 'test-model',
'stopReason' => 'provider-specific',
]);
$this->assertSame('provider-specific', $result->stopReason);
$this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']);
}
}