[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 `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`.
|
* 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.
|
* 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
|
0.7.0
|
||||||
-----
|
-----
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,42 @@ $client = Client::builder()
|
||||||
->build();
|
->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]
|
> [!IMPORTANT]
|
||||||
> **Error Handling in Sampling Callbacks:**
|
> **Error Handling in Sampling Callbacks:**
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class MyService
|
||||||
|
|
||||||
## Sampling
|
## 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:
|
execute "completions" or "generations" with a language model for them:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
@ -41,12 +41,16 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper
|
||||||
|
|
||||||
The `sample` method accepts four arguments:
|
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`
|
2. `maxTokens`, which defaults to `1000`
|
||||||
3. `timeout` in seconds, which defaults to `120`
|
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
|
## Logging
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
public readonly ?bool $elicitation = null,
|
public readonly ?bool $elicitation = null,
|
||||||
public readonly ?array $experimental = null,
|
public readonly ?array $experimental = null,
|
||||||
public readonly ?array $extensions = 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{
|
* roots?: array{
|
||||||
* listChanged?: bool,
|
* listChanged?: bool,
|
||||||
* },
|
* },
|
||||||
* sampling?: bool,
|
* sampling?: array{context?: mixed, tools?: mixed}|object,
|
||||||
* elicitation?: bool,
|
* elicitation?: bool,
|
||||||
* experimental?: array<string, mixed>,
|
* experimental?: array<string, mixed>,
|
||||||
* extensions?: array<string, mixed>,
|
* extensions?: array<string, mixed>,
|
||||||
|
|
@ -57,8 +59,17 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
}
|
}
|
||||||
|
|
||||||
$sampling = null;
|
$sampling = null;
|
||||||
|
$samplingContext = null;
|
||||||
|
$samplingTools = null;
|
||||||
if (isset($data['sampling'])) {
|
if (isset($data['sampling'])) {
|
||||||
$sampling = true;
|
$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;
|
$elicitation = null;
|
||||||
|
|
@ -73,6 +84,8 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
$elicitation,
|
$elicitation,
|
||||||
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
|
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
|
||||||
\is_array($data['extensions'] ?? null) ? $data['extensions'] : 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();
|
$data['sampling'] = new \stdClass();
|
||||||
|
if ($this->samplingContext) {
|
||||||
|
$data['sampling']->context = new \stdClass();
|
||||||
|
}
|
||||||
|
if ($this->samplingTools) {
|
||||||
|
$data['sampling']->tools = new \stdClass();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->elicitation) {
|
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.
|
* 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{
|
* @phpstan-type SamplingMessageData = array{
|
||||||
* role: 'user'|'assistant',
|
* 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>
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
*/
|
*/
|
||||||
class SamplingMessage extends Content
|
class SamplingMessage extends Content
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param SamplingContent|list<SamplingContent> $content
|
||||||
|
* @param ?array<string, mixed> $meta
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly Role $role,
|
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');
|
parent::__construct('sampling');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,18 +76,26 @@ class SamplingMessage extends Content
|
||||||
|
|
||||||
$contentData = $data['content'];
|
$contentData = $data['content'];
|
||||||
$contentType = $contentData['type'] ?? null;
|
$contentType = $contentData['type'] ?? null;
|
||||||
if (!\is_string($contentType)) {
|
if (null !== $contentType && !\is_string($contentType)) {
|
||||||
throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.');
|
throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contentInstance = match ($contentType) {
|
$isSingleContent = null !== $contentType;
|
||||||
'text' => TextContent::fromArray($contentData),
|
$contentItems = $isSingleContent ? [$contentData] : $contentData;
|
||||||
'image' => ImageContent::fromArray($contentData),
|
$content = [];
|
||||||
'audio' => AudioContent::fromArray($contentData),
|
|
||||||
default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)),
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return [
|
$data = [
|
||||||
'role' => $this->role->value,
|
'role' => $this->role->value,
|
||||||
'content' => $this->content,
|
'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\Enum\SamplingContext;
|
||||||
use Mcp\Schema\JsonRpc\Request;
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
use Mcp\Schema\ModelPreferences;
|
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.
|
* 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
|
* @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.
|
* the caller), to be attached to the prompt. The client MAY ignore this request.
|
||||||
* Allowed values: "none", "thisServer", "allServers"
|
* 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 ?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 ?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
|
* @param ?array<string, mixed> $metadata Optional metadata to pass through to the LLM provider. The format of
|
||||||
* this metadata is provider-specific.
|
* 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 function __construct(
|
||||||
public readonly array $messages,
|
public readonly array $messages,
|
||||||
|
|
@ -51,12 +57,19 @@ final class CreateSamplingMessageRequest extends Request
|
||||||
public readonly ?float $temperature = null,
|
public readonly ?float $temperature = null,
|
||||||
public readonly ?array $stopSequences = null,
|
public readonly ?array $stopSequences = null,
|
||||||
public readonly ?array $metadata = null,
|
public readonly ?array $metadata = null,
|
||||||
|
public readonly ?array $tools = null,
|
||||||
|
public readonly ?ToolChoice $toolChoice = null,
|
||||||
) {
|
) {
|
||||||
foreach ($this->messages as $message) {
|
foreach ($this->messages as $message) {
|
||||||
if (!$message instanceof SamplingMessage) {
|
if (!$message instanceof SamplingMessage) {
|
||||||
throw new InvalidArgumentException('Messages must be instance of 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
|
public static function getMethod(): string
|
||||||
|
|
@ -122,6 +135,34 @@ final class CreateSamplingMessageRequest extends Request
|
||||||
throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.');
|
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(
|
return new self(
|
||||||
$messages,
|
$messages,
|
||||||
$params['maxTokens'],
|
$params['maxTokens'],
|
||||||
|
|
@ -131,6 +172,8 @@ final class CreateSamplingMessageRequest extends Request
|
||||||
isset($params['temperature']) ? (float) $params['temperature'] : null,
|
isset($params['temperature']) ? (float) $params['temperature'] : null,
|
||||||
$params['stopSequences'] ?? null,
|
$params['stopSequences'] ?? null,
|
||||||
$params['metadata'] ?? null,
|
$params['metadata'] ?? null,
|
||||||
|
$tools,
|
||||||
|
$toolChoice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +186,9 @@ final class CreateSamplingMessageRequest extends Request
|
||||||
* includeContext?: string,
|
* includeContext?: string,
|
||||||
* temperature?: float,
|
* temperature?: float,
|
||||||
* stopSequences?: string[],
|
* stopSequences?: string[],
|
||||||
* metadata?: array<string, mixed>
|
* metadata?: array<string, mixed>,
|
||||||
|
* tools?: Tool[],
|
||||||
|
* toolChoice?: ToolChoice,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
protected function getParams(): array
|
protected function getParams(): array
|
||||||
|
|
@ -177,6 +222,14 @@ final class CreateSamplingMessageRequest extends Request
|
||||||
$params['metadata'] = $this->metadata;
|
$params['metadata'] = $this->metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $this->tools) {
|
||||||
|
$params['tools'] = $this->tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->toolChoice) {
|
||||||
|
$params['toolChoice'] = $this->toolChoice;
|
||||||
|
}
|
||||||
|
|
||||||
return $params;
|
return $params;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use Mcp\Exception\InvalidArgumentException;
|
||||||
use Mcp\Schema\Content\AudioContent;
|
use Mcp\Schema\Content\AudioContent;
|
||||||
use Mcp\Schema\Content\ImageContent;
|
use Mcp\Schema\Content\ImageContent;
|
||||||
use Mcp\Schema\Content\TextContent;
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Content\ToolUseContent;
|
||||||
use Mcp\Schema\Enum\Role;
|
use Mcp\Schema\Enum\Role;
|
||||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
|
|
||||||
|
|
@ -28,17 +29,30 @@ use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
class CreateSamplingMessageResult implements ResultInterface
|
class CreateSamplingMessageResult implements ResultInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param Role $role the role of the message
|
* @param Role $role the role of the message
|
||||||
* @param TextContent|ImageContent|AudioContent $content the content 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 $model the name of the model that generated the message
|
||||||
* @param string|null $stopReason the reason why sampling stopped, if known
|
* @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 function __construct(
|
||||||
public readonly Role $role,
|
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 $model,
|
||||||
public readonly ?string $stopReason = null,
|
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'];
|
$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;
|
$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
|
* @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;
|
$type = $contentData['type'] ?? null;
|
||||||
|
|
||||||
|
|
@ -85,6 +114,7 @@ class CreateSamplingMessageResult implements ResultInterface
|
||||||
'text' => TextContent::fromArray($contentData),
|
'text' => TextContent::fromArray($contentData),
|
||||||
'image' => ImageContent::fromArray($contentData),
|
'image' => ImageContent::fromArray($contentData),
|
||||||
'audio' => AudioContent::fromArray($contentData),
|
'audio' => AudioContent::fromArray($contentData),
|
||||||
|
'tool_use' => ToolUseContent::fromArray($contentData),
|
||||||
default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)),
|
default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -92,9 +122,10 @@ class CreateSamplingMessageResult implements ResultInterface
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* role: string,
|
* role: string,
|
||||||
* content: TextContent|ImageContent|AudioContent,
|
* content: TextContent|ImageContent|AudioContent|ToolUseContent|list<TextContent|ImageContent|AudioContent|ToolUseContent>,
|
||||||
* model: string,
|
* model: string,
|
||||||
* stopReason?: string,
|
* stopReason?: string,
|
||||||
|
* _meta?: array<string, mixed>,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
|
|
@ -109,6 +140,10 @@ class CreateSamplingMessageResult implements ResultInterface
|
||||||
$result['stopReason'] = $this->stopReason;
|
$result['stopReason'] = $this->stopReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $this->meta) {
|
||||||
|
$result['_meta'] = $this->meta;
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
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\CreateSamplingMessageResult;
|
||||||
use Mcp\Schema\Result\ElicitResult;
|
use Mcp\Schema\Result\ElicitResult;
|
||||||
use Mcp\Schema\Result\ListRootsResult;
|
use Mcp\Schema\Result\ListRootsResult;
|
||||||
|
use Mcp\Schema\Tool;
|
||||||
|
use Mcp\Schema\ToolChoice;
|
||||||
use Mcp\Server\Session\SessionInterface;
|
use Mcp\Server\Session\SessionInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -67,6 +69,8 @@ use Mcp\Server\Session\SessionInterface;
|
||||||
* includeContext?: SamplingContext,
|
* includeContext?: SamplingContext,
|
||||||
* stopSequences?: string[],
|
* stopSequences?: string[],
|
||||||
* metadata?: array<string, mixed>,
|
* metadata?: array<string, mixed>,
|
||||||
|
* tools?: Tool[],
|
||||||
|
* toolChoice?: ToolChoice,
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
|
@ -124,6 +128,9 @@ class ClientGateway
|
||||||
* @param int $maxTokens Maximum tokens to generate
|
* @param int $maxTokens Maximum tokens to generate
|
||||||
* @param int $timeout The timeout in seconds
|
* @param int $timeout The timeout in seconds
|
||||||
* @param SampleOptions $options Additional sampling options (temperature, etc.)
|
* @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
|
* @return CreateSamplingMessageResult The sampling response
|
||||||
*
|
*
|
||||||
|
|
@ -152,6 +159,8 @@ class ClientGateway
|
||||||
temperature: $options['temperature'] ?? null,
|
temperature: $options['temperature'] ?? null,
|
||||||
stopSequences: $options['stopSequences'] ?? null,
|
stopSequences: $options['stopSequences'] ?? null,
|
||||||
metadata: $options['metadata'] ?? null,
|
metadata: $options['metadata'] ?? null,
|
||||||
|
tools: $options['tools'] ?? null,
|
||||||
|
toolChoice: $options['toolChoice'] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
$response = $this->request($request, $timeout);
|
$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\SamplingMessage;
|
||||||
use Mcp\Schema\Content\TextContent;
|
use Mcp\Schema\Content\TextContent;
|
||||||
use Mcp\Schema\Enum\Role;
|
use Mcp\Schema\Enum\Role;
|
||||||
|
use Mcp\Schema\Enum\ToolChoiceMode;
|
||||||
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||||
|
use Mcp\Schema\Tool;
|
||||||
|
use Mcp\Schema\ToolChoice;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class CreateSamplingMessageRequestTest extends TestCase
|
final class CreateSamplingMessageRequestTest extends TestCase
|
||||||
|
|
@ -48,4 +51,23 @@ final class CreateSamplingMessageRequestTest extends TestCase
|
||||||
/* @phpstan-ignore argument.type */
|
/* @phpstan-ignore argument.type */
|
||||||
new CreateSamplingMessageRequest($messages, 150);
|
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