[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:
parent
d8cc21a345
commit
82aac972c1
16 changed files with 682 additions and 27 deletions
|
|
@ -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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
99
src/Schema/Content/ToolResultContent.php
Normal file
99
src/Schema/Content/ToolResultContent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/Schema/Content/ToolUseContent.php
Normal file
75
src/Schema/Content/ToolUseContent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/Schema/Enum/ToolChoiceMode.php
Normal file
19
src/Schema/Enum/ToolChoiceMode.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 ToolChoiceMode: string
|
||||
{
|
||||
case Auto = 'auto';
|
||||
case Required = 'required';
|
||||
case None = 'none';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
51
src/Schema/ToolChoice.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
45
tests/Unit/Schema/ClientCapabilitiesSamplingTest.php
Normal file
45
tests/Unit/Schema/ClientCapabilitiesSamplingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
tests/Unit/Schema/Content/SamplingToolContentTest.php
Normal file
76
tests/Unit/Schema/Content/SamplingToolContentTest.php
Normal 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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php
Normal file
53
tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue