[Schema][Server] Harden sampling tools against the spec (#420)
* harden sampling tools against the spec Report tool-flow violations as -32602 instead of dropping the request, accept resource_link in tool results, gate tools on sampling.tools, and reject empty content. * normalize keyed content arrays to lists array_filter() and friends preserve keys, and a keyed array serializes as a JSON object instead of the content-block array the schema requires.
This commit is contained in:
parent
becce29c41
commit
43815e38a8
24 changed files with 1212 additions and 167 deletions
|
|
@ -13,7 +13,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`.
|
||||
* 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.
|
||||
* 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. Adds `ClientGateway::supportsSamplingTools()` / `supportsSamplingContext()` to check the sub-capabilities before sending, and `CreateSamplingMessageRequest::validateToolFlow()`, which asserts the spec's tool-flow rules across the whole message list — the client handler rejects a violating request with `-32602` instead of leaving it unanswered, and the gateway refuses to send one.
|
||||
* [BC Break] `SamplingMessage::$content` and `CreateSamplingMessageResult::$content` may now hold a list of content blocks instead of a single one, so code reading them directly must handle both. Use the new `getContentBlocks()` on either class to always get a list.
|
||||
* [BC Break] `CreateSamplingMessageResult` now rejects any role other than `assistant`, and rejects empty content, as the specification requires.
|
||||
|
||||
0.7.0
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -792,7 +792,7 @@ $samplingCallback = new class implements SamplingCallbackInterface {
|
|||
role: Role::Assistant,
|
||||
content: new TextContent($response),
|
||||
model: 'mock-llm',
|
||||
stopReason: 'end_turn',
|
||||
stopReason: 'endTurn',
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new SamplingException(
|
||||
|
|
|
|||
|
|
@ -58,8 +58,33 @@ The `sample` method accepts four arguments:
|
|||
4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`,
|
||||
`stopSequences`, `metadata`, `tools`, and `toolChoice`
|
||||
|
||||
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.
|
||||
Both `tools`/`toolChoice` and `includeContext` are gated on what the client advertised, so check before sending:
|
||||
|
||||
```php
|
||||
if ($clientGateway->supportsSamplingTools()) {
|
||||
$result = $clientGateway->sample($messages, options: ['tools' => $tools]);
|
||||
}
|
||||
```
|
||||
|
||||
A server **must not** send `tools` or `toolChoice` to a client that did not advertise `sampling.tools`. The
|
||||
`includeContext` values other than `none` are soft-deprecated and should only be sent when the client advertises
|
||||
`sampling.context` — `supportsSamplingContext()` reports that one.
|
||||
|
||||
### Tool loops
|
||||
|
||||
When the model wants to call a tool, the result comes back with `stopReason: 'toolUse'` and one or more
|
||||
`ToolUseContent` blocks. Execute them, then send a follow-up request with the assistant's message and a user message
|
||||
carrying a matching `ToolResultContent` for every `ToolUseContent`:
|
||||
|
||||
```php
|
||||
$messages[] = new SamplingMessage(Role::Assistant, $result->content);
|
||||
$messages[] = new SamplingMessage(Role::User, [new ToolResultContent($toolUse->id, [new TextContent($output)])]);
|
||||
```
|
||||
|
||||
The specification is strict about the shape of that exchange: tool results may not be mixed with other content in a
|
||||
message, and every tool use must be answered before the conversation continues. `sample()` checks these rules before
|
||||
sending and throws an `InvalidArgumentException` rather than letting the client reject the request with `-32602`.
|
||||
Use `$result->getContentBlocks()` to iterate the response regardless of whether it holds one block or a list.
|
||||
|
||||
[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages)
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli
|
|||
role: Role::Assistant,
|
||||
content: new TextContent($mockResponse),
|
||||
model: 'mock-gpt-4',
|
||||
stopReason: 'end_turn',
|
||||
stopReason: 'endTurn',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ $samplingRequestHandler = new SamplingRequestHandler(new class implements Sampli
|
|||
role: Role::Assistant,
|
||||
content: new TextContent($mockResponse),
|
||||
model: 'mock-gpt-4',
|
||||
stopReason: 'end_turn',
|
||||
stopReason: 'endTurn',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Client\Handler\Request;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Exception\SamplingException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
|
|
@ -50,6 +51,14 @@ class SamplingRequestHandler implements RequestHandlerInterface
|
|||
{
|
||||
\assert($request instanceof CreateSamplingMessageRequest);
|
||||
|
||||
try {
|
||||
$request->validateToolFlow();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->warning('Rejecting sampling request violating the tool flow', ['exception' => $e]);
|
||||
|
||||
return Error::forInvalidParams($e->getMessage(), $request->getId());
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->callback->__invoke($request);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ class ClientCapabilities implements \JsonSerializable
|
|||
{
|
||||
/**
|
||||
* @param array<string, mixed> $experimental
|
||||
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
|
||||
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
|
||||
* @param ?bool $samplingContext the `sampling.context` sub-capability
|
||||
* @param ?bool $samplingTools the `sampling.tools` sub-capability
|
||||
*
|
||||
* The two sampling sub-capabilities trail `extensions` rather than sitting next to
|
||||
* `sampling` so that existing positional calls keep working. Pass them by name.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?bool $roots = false,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ use Mcp\Schema\Enum\Role;
|
|||
/**
|
||||
* Describes a message issued to or received from an LLM API during sampling.
|
||||
*
|
||||
* Structural validity is enforced here, but the spec's tool-flow rules (which role may
|
||||
* carry which block, tool results not being mixed with other content, every tool use
|
||||
* being answered) are not: they span the whole message list and must be reportable as
|
||||
* an "invalid params" error rather than as a parse failure. They live in
|
||||
* {@see \Mcp\Schema\Request\CreateSamplingMessageRequest::validateToolFlow()}.
|
||||
*
|
||||
* @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent
|
||||
* @phpstan-type SamplingMessageData = array{
|
||||
* @phpstan-type SamplingMessageData array{
|
||||
* role: 'user'|'assistant',
|
||||
* content: array<string, mixed>|array<array<string, mixed>>,
|
||||
* _meta?: array<string, mixed>
|
||||
* content: array<string, mixed>|list<array<string, mixed>>,
|
||||
* _meta?: array<string, mixed>,
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
|
|
@ -29,35 +35,48 @@ use Mcp\Schema\Enum\Role;
|
|||
class SamplingMessage extends Content
|
||||
{
|
||||
/**
|
||||
* @param SamplingContent|list<SamplingContent> $content
|
||||
* @param ?array<string, mixed> $meta
|
||||
* @var SamplingContent|list<SamplingContent>
|
||||
*/
|
||||
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content;
|
||||
|
||||
/**
|
||||
* @param SamplingContent|array<SamplingContent> $content keys are discarded, the property always holds a list
|
||||
* @param ?array<string, mixed> $meta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Role $role,
|
||||
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content,
|
||||
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 (\is_array($content)) {
|
||||
if ([] === $content) {
|
||||
throw new InvalidArgumentException('Sampling message content must not be empty.');
|
||||
}
|
||||
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.');
|
||||
|
||||
foreach ($content 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.');
|
||||
}
|
||||
}
|
||||
|
||||
// array_filter() and friends preserve keys, and a keyed array serializes
|
||||
// as a JSON object rather than the array the schema requires.
|
||||
$content = array_values($content);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
$this->content = $content;
|
||||
|
||||
parent::__construct('sampling');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SamplingContent>
|
||||
*/
|
||||
public function getContentBlocks(): array
|
||||
{
|
||||
return \is_array($this->content) ? $this->content : [$this->content];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SamplingMessageData $data
|
||||
*/
|
||||
|
|
@ -66,7 +85,7 @@ class SamplingMessage extends Content
|
|||
if (!isset($data['role']) || !\is_string($data['role'])) {
|
||||
throw new InvalidArgumentException('Missing or invalid "role" in SamplingMessage data.');
|
||||
}
|
||||
if (!isset($data['content']) || !\is_array($data['content'])) {
|
||||
if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) {
|
||||
throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.');
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +118,11 @@ class SamplingMessage extends Content
|
|||
}
|
||||
|
||||
/**
|
||||
* @return SamplingMessageData
|
||||
* @return array{
|
||||
* role: string,
|
||||
* content: SamplingContent|list<SamplingContent>,
|
||||
* _meta?: array<string, mixed>,
|
||||
* }
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,27 +15,41 @@ use Mcp\Exception\InvalidArgumentException;
|
|||
|
||||
/**
|
||||
* The result of a tool use, provided by the user back to the assistant.
|
||||
*
|
||||
* Carries the same content blocks as a `tools/call` result, so it accepts everything
|
||||
* {@see \Mcp\Schema\Result\CallToolResult} does.
|
||||
*
|
||||
* @phpstan-type ToolResultBlock TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource
|
||||
*/
|
||||
final class ToolResultContent extends Content
|
||||
{
|
||||
/**
|
||||
* @param Content[] $content
|
||||
* @param ?array<string, mixed> $structuredContent
|
||||
* @param ?array<string, mixed> $meta
|
||||
* @var list<ToolResultBlock>
|
||||
*/
|
||||
public readonly array $content;
|
||||
|
||||
/**
|
||||
* @param array<ToolResultBlock> $content keys are discarded, the property always holds a list
|
||||
* @param ?array<string, mixed> $structuredContent
|
||||
* @param ?array<string, mixed> $meta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $toolUseId,
|
||||
public readonly array $content,
|
||||
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) {
|
||||
if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof ResourceLink && !$item instanceof EmbeddedResource) {
|
||||
throw new InvalidArgumentException('Tool result content must contain standard content blocks.');
|
||||
}
|
||||
}
|
||||
|
||||
// array_filter() and friends preserve keys, and a keyed array serializes
|
||||
// as a JSON object rather than the array the schema requires.
|
||||
$this->content = array_values($content);
|
||||
|
||||
parent::__construct('tool_result');
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +75,7 @@ final class ToolResultContent extends Content
|
|||
'text' => TextContent::fromArray($item),
|
||||
'image' => ImageContent::fromArray($item),
|
||||
'audio' => AudioContent::fromArray($item),
|
||||
'resource_link' => ResourceLink::fromArray($item),
|
||||
'resource' => EmbeddedResource::fromArray($item),
|
||||
default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)),
|
||||
};
|
||||
|
|
@ -84,9 +99,13 @@ final class ToolResultContent extends Content
|
|||
'type' => $this->type,
|
||||
'toolUseId' => $this->toolUseId,
|
||||
'content' => $this->content,
|
||||
'isError' => $this->isError,
|
||||
];
|
||||
|
||||
// Optional in the schema with a default of false, so only sent when it is true.
|
||||
if ($this->isError) {
|
||||
$data['isError'] = true;
|
||||
}
|
||||
|
||||
if (null !== $this->structuredContent) {
|
||||
$data['structuredContent'] = $this->structuredContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ namespace Mcp\Schema\Request;
|
|||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Content\SamplingMessage;
|
||||
use Mcp\Schema\Content\ToolResultContent;
|
||||
use Mcp\Schema\Content\ToolUseContent;
|
||||
use Mcp\Schema\Enum\Role;
|
||||
use Mcp\Schema\Enum\SamplingContext;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\ModelPreferences;
|
||||
|
|
@ -177,6 +180,64 @@ final class CreateSamplingMessageRequest extends Request
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the spec's tool-flow rules over the whole message list.
|
||||
*
|
||||
* These are deliberately kept out of the hydration path: a violation is an
|
||||
* "invalid params" condition the peer must be told about, not a parse failure
|
||||
* that would leave the request unanswered. Call it from whatever boundary can
|
||||
* report it — the request handler when receiving, the gateway when sending.
|
||||
*
|
||||
* @throws InvalidArgumentException on the first violation found
|
||||
*/
|
||||
public function validateToolFlow(): void
|
||||
{
|
||||
$pendingToolUseIds = [];
|
||||
|
||||
foreach ($this->messages as $message) {
|
||||
$blocks = $message->getContentBlocks();
|
||||
|
||||
$toolResults = array_filter($blocks, static fn ($block): bool => $block instanceof ToolResultContent);
|
||||
$toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent);
|
||||
|
||||
if ($toolResults && \count($toolResults) !== \count($blocks)) {
|
||||
throw new InvalidArgumentException('Tool results mixed with other content.');
|
||||
}
|
||||
|
||||
if (Role::User === $message->role && $toolUses) {
|
||||
throw new InvalidArgumentException('ToolUseContent is only valid in assistant sampling messages.');
|
||||
}
|
||||
|
||||
if (Role::Assistant === $message->role && $toolResults) {
|
||||
throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.');
|
||||
}
|
||||
|
||||
if ($pendingToolUseIds && !$toolResults) {
|
||||
throw new InvalidArgumentException('Tool result missing in request.');
|
||||
}
|
||||
|
||||
foreach ($toolResults as $toolResult) {
|
||||
$matched = array_search($toolResult->toolUseId, $pendingToolUseIds, true);
|
||||
if (false === $matched) {
|
||||
throw new InvalidArgumentException(\sprintf('Tool result "%s" does not answer a preceding tool use.', $toolResult->toolUseId));
|
||||
}
|
||||
unset($pendingToolUseIds[$matched]);
|
||||
}
|
||||
|
||||
if ($pendingToolUseIds) {
|
||||
throw new InvalidArgumentException('Tool result missing in request.');
|
||||
}
|
||||
|
||||
foreach ($toolUses as $toolUse) {
|
||||
$pendingToolUseIds[] = $toolUse->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pendingToolUseIds) {
|
||||
throw new InvalidArgumentException('Tool result missing in request.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* messages: SamplingMessage[],
|
||||
|
|
|
|||
|
|
@ -29,17 +29,22 @@ use Mcp\Schema\JsonRpc\ResultInterface;
|
|||
class CreateSamplingMessageResult implements ResultInterface
|
||||
{
|
||||
/**
|
||||
* @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
|
||||
* @var TextContent|ImageContent|AudioContent|ToolUseContent|list<TextContent|ImageContent|AudioContent|ToolUseContent>
|
||||
*/
|
||||
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content;
|
||||
|
||||
/**
|
||||
* @param Role $role the role of the message
|
||||
* @param TextContent|ImageContent|AudioContent|ToolUseContent|array<TextContent|ImageContent|AudioContent|ToolUseContent> $content The content of the message. Keys are discarded, the property always holds a list.
|
||||
* @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|ToolUseContent|array $content,
|
||||
TextContent|ImageContent|AudioContent|ToolUseContent|array $content,
|
||||
public readonly string $model,
|
||||
public readonly ?string $stopReason = null,
|
||||
public readonly ?array $meta = null,
|
||||
|
|
@ -48,11 +53,31 @@ class CreateSamplingMessageResult implements ResultInterface
|
|||
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.');
|
||||
if (\is_array($content)) {
|
||||
if ([] === $content) {
|
||||
throw new InvalidArgumentException('CreateSamplingMessageResult content must not be empty.');
|
||||
}
|
||||
|
||||
foreach ($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.');
|
||||
}
|
||||
}
|
||||
|
||||
// array_filter() and friends preserve keys, and a keyed array serializes
|
||||
// as a JSON object rather than the array the schema requires.
|
||||
$content = array_values($content);
|
||||
}
|
||||
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TextContent|ImageContent|AudioContent|ToolUseContent>
|
||||
*/
|
||||
public function getContentBlocks(): array
|
||||
{
|
||||
return \is_array($this->content) ? $this->content : [$this->content];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +89,7 @@ class CreateSamplingMessageResult implements ResultInterface
|
|||
throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.');
|
||||
}
|
||||
|
||||
if (!isset($data['content']) || !\is_array($data['content'])) {
|
||||
if (!isset($data['content']) || !\is_array($data['content']) || [] === $data['content']) {
|
||||
throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@ class ClientGateway
|
|||
toolChoice: $options['toolChoice'] ?? null,
|
||||
);
|
||||
|
||||
// Fail here rather than letting the client reject the request with -32602.
|
||||
$request->validateToolFlow();
|
||||
|
||||
$response = $this->request($request, $timeout);
|
||||
|
||||
if ($response instanceof Error) {
|
||||
|
|
@ -276,6 +279,47 @@ class ClientGateway
|
|||
return \array_key_exists('sampling', $capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connected client supports tools during sampling.
|
||||
*
|
||||
* Per the spec a server MUST NOT put `tools` or `toolChoice` on a
|
||||
* `sampling/createMessage` request unless the client advertised
|
||||
* `sampling.tools`, so check this before passing either option to
|
||||
* {@see self::sample()}.
|
||||
*
|
||||
* @return bool True if the client supports tool-enabled sampling, false otherwise
|
||||
*/
|
||||
public function supportsSamplingTools(): bool
|
||||
{
|
||||
return $this->hasSamplingSubCapability('tools');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connected client supports context inclusion during sampling.
|
||||
*
|
||||
* The `includeContext` values other than `none` are soft-deprecated and should
|
||||
* only be sent when the client advertised `sampling.context`.
|
||||
*
|
||||
* @return bool True if the client supports sampling context, false otherwise
|
||||
*/
|
||||
public function supportsSamplingContext(): bool
|
||||
{
|
||||
return $this->hasSamplingSubCapability('context');
|
||||
}
|
||||
|
||||
private function hasSamplingSubCapability(string $name): bool
|
||||
{
|
||||
$capabilities = (array) $this->session->get('client_capabilities', []);
|
||||
$sampling = $capabilities['sampling'] ?? null;
|
||||
|
||||
if (!\is_array($sampling) && !\is_object($sampling)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// MCP spec: capability presence indicates support (value is typically {} or [])
|
||||
return \array_key_exists($name, (array) $sampling);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the client and wait for a response (blocking).
|
||||
*
|
||||
|
|
|
|||
78
tests/Integration/Fixture/sampling_tools.php
Normal file
78
tests/Integration/Fixture/sampling_tools.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Server for {@see \Mcp\Tests\Integration\SamplingToolsTest}.
|
||||
*/
|
||||
|
||||
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 Mcp\Schema\Tool;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Transport\StdioTransport;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/vendor/autoload.php';
|
||||
|
||||
$weather = new Tool(
|
||||
'get_weather',
|
||||
null,
|
||||
['type' => 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']],
|
||||
'Get current weather for a city',
|
||||
null,
|
||||
);
|
||||
|
||||
Server::builder()
|
||||
->setServerInfo('integration-server', '1.0.0')
|
||||
->addTool(
|
||||
static function (RequestContext $context, string $city) use ($weather): string {
|
||||
$gateway = $context->getClientGateway();
|
||||
|
||||
// The spec forbids sending tools to a client that did not advertise
|
||||
// sampling.tools, so the loop is only entered when it did.
|
||||
if (!$gateway->supportsSamplingTools()) {
|
||||
return 'client cannot use tools during sampling';
|
||||
}
|
||||
|
||||
$messages = [new SamplingMessage(Role::User, new TextContent(sprintf('Weather in %s?', $city)))];
|
||||
|
||||
$answer = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]);
|
||||
$messages[] = new SamplingMessage(Role::Assistant, $answer->content);
|
||||
|
||||
$toolResults = [];
|
||||
foreach ($answer->getContentBlocks() as $block) {
|
||||
if ($block instanceof ToolUseContent) {
|
||||
$toolResults[] = new ToolResultContent(
|
||||
$block->id,
|
||||
[new TextContent(sprintf('%s: 18 C', $block->input['city'] ?? 'unknown'))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $toolResults) {
|
||||
return 'the model asked for no tools';
|
||||
}
|
||||
|
||||
$messages[] = new SamplingMessage(Role::User, $toolResults);
|
||||
|
||||
$final = $gateway->sample($messages, maxTokens: 64, options: ['tools' => [$weather]]);
|
||||
assert($final->content instanceof TextContent);
|
||||
|
||||
return sprintf('%s (%s)', $final->content->text, $final->stopReason);
|
||||
},
|
||||
name: 'weather_report',
|
||||
description: 'Reports weather by running a sampling tool loop.',
|
||||
)
|
||||
->build()
|
||||
->run(new StdioTransport());
|
||||
135
tests/Integration/SamplingToolsTest.php
Normal file
135
tests/Integration/SamplingToolsTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?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\Integration;
|
||||
|
||||
use Mcp\Client\Builder as ClientBuilder;
|
||||
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
|
||||
use Mcp\Client\Handler\Request\SamplingRequestHandler;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Content\ToolResultContent;
|
||||
use Mcp\Schema\Content\ToolUseContent;
|
||||
use Mcp\Schema\Enum\Role;
|
||||
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||
use Mcp\Schema\Result\CreateSamplingMessageResult;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* Sampling with tools: the multi-turn tool loop between server, client and model.
|
||||
*
|
||||
* @see Fixture/sampling_tools.php for the server under test
|
||||
*/
|
||||
final class SamplingToolsTest extends IntegrationTestCase
|
||||
{
|
||||
#[TestDox('the server runs a full tool loop and gets the model\'s final answer')]
|
||||
public function testToolLoopCompletes(): void
|
||||
{
|
||||
$client = $this->connect('sampling_tools', $this->clientSamplingWithTools());
|
||||
|
||||
$result = $client->callTool('weather_report', ['city' => 'Paris']);
|
||||
|
||||
$this->assertInstanceOf(TextContent::class, $result->content[0]);
|
||||
$this->assertSame('Paris: 18 C is the answer. (endTurn)', $result->content[0]->text);
|
||||
}
|
||||
|
||||
#[TestDox('the tools the server offered arrive at the client')]
|
||||
public function testToolsReachTheClient(): void
|
||||
{
|
||||
/** @var \ArrayObject<int, CreateSamplingMessageRequest> $seen */
|
||||
$seen = new \ArrayObject();
|
||||
$client = $this->connect('sampling_tools', $this->clientSamplingWithTools($seen));
|
||||
|
||||
$client->callTool('weather_report', ['city' => 'Paris']);
|
||||
|
||||
$this->assertCount(2, $seen);
|
||||
$this->assertSame('get_weather', $seen[0]->tools[0]->name);
|
||||
|
||||
// Second turn carries the assistant's tool use and the server's tool result.
|
||||
$this->assertCount(3, $seen[1]->messages);
|
||||
$this->assertInstanceOf(ToolUseContent::class, $seen[1]->messages[1]->getContentBlocks()[0]);
|
||||
$toolResult = $seen[1]->messages[2]->getContentBlocks()[0];
|
||||
$this->assertInstanceOf(ToolResultContent::class, $toolResult);
|
||||
$this->assertSame('call-1', $toolResult->toolUseId);
|
||||
}
|
||||
|
||||
#[TestDox('a client that did not advertise sampling.tools is never sent tools')]
|
||||
public function testClientWithoutSamplingToolsIsNotOfferedTools(): void
|
||||
{
|
||||
$client = $this->connect('sampling_tools', $this->clientBuilder()
|
||||
->setCapabilities(new ClientCapabilities(sampling: true))
|
||||
->addRequestHandler(new SamplingRequestHandler($this->neverCalled())));
|
||||
|
||||
$result = $client->callTool('weather_report', ['city' => 'Paris']);
|
||||
|
||||
$this->assertInstanceOf(TextContent::class, $result->content[0]);
|
||||
$this->assertSame('client cannot use tools during sampling', $result->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \ArrayObject<int, CreateSamplingMessageRequest>|null $seen collects what the server asked for
|
||||
*/
|
||||
private function clientSamplingWithTools(?\ArrayObject $seen = null): ClientBuilder
|
||||
{
|
||||
$callback = new class($seen ?? new \ArrayObject()) implements SamplingCallbackInterface {
|
||||
/** @param \ArrayObject<int, CreateSamplingMessageRequest> $seen */
|
||||
public function __construct(private readonly \ArrayObject $seen)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
|
||||
{
|
||||
$this->seen[] = $request;
|
||||
|
||||
$lastMessage = $request->messages[\count($request->messages) - 1];
|
||||
$answeredTools = array_filter(
|
||||
$lastMessage->getContentBlocks(),
|
||||
static fn ($block): bool => $block instanceof ToolResultContent,
|
||||
);
|
||||
|
||||
// First turn: ask for the tool. Second: answer from its result.
|
||||
if ([] === $answeredTools) {
|
||||
return new CreateSamplingMessageResult(
|
||||
Role::Assistant,
|
||||
[new ToolUseContent('call-1', 'get_weather', ['city' => 'Paris'])],
|
||||
'test-model',
|
||||
'toolUse',
|
||||
);
|
||||
}
|
||||
|
||||
$toolResult = reset($answeredTools);
|
||||
$text = $toolResult->content[0];
|
||||
\assert($text instanceof TextContent);
|
||||
|
||||
return new CreateSamplingMessageResult(
|
||||
Role::Assistant,
|
||||
new TextContent(\sprintf('%s is the answer.', $text->text)),
|
||||
'test-model',
|
||||
'endTurn',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return $this->clientBuilder()
|
||||
->setCapabilities(new ClientCapabilities(sampling: true, samplingTools: true))
|
||||
->addRequestHandler(new SamplingRequestHandler($callback));
|
||||
}
|
||||
|
||||
private function neverCalled(): SamplingCallbackInterface
|
||||
{
|
||||
return new class implements SamplingCallbackInterface {
|
||||
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
|
||||
{
|
||||
throw new \LogicException('The server must not sample a client that cannot use tools.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
114
tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php
Normal file
114
tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?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\Client\Handler\Request;
|
||||
|
||||
use Mcp\Client\Handler\Request\SamplingCallbackInterface;
|
||||
use Mcp\Client\Handler\Request\SamplingRequestHandler;
|
||||
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 Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||
use Mcp\Schema\Result\CreateSamplingMessageResult;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SamplingRequestHandlerTest extends TestCase
|
||||
{
|
||||
public function testHandleReturnsResponseForValidToolFlow(): void
|
||||
{
|
||||
$handler = new SamplingRequestHandler($this->callbackReturningText());
|
||||
|
||||
$request = $this->requestFor([
|
||||
new SamplingMessage(Role::User, new TextContent('Weather in Paris?')),
|
||||
new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', ['city' => 'Paris'])),
|
||||
new SamplingMessage(Role::User, new ToolResultContent('call-1', [new TextContent('18 C')])),
|
||||
]);
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
$this->assertSame($request->getId(), $response->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spec asks for -32602 on both of these, and the callback must not run.
|
||||
*
|
||||
* @return iterable<string, array{SamplingMessage[], string}>
|
||||
*/
|
||||
public static function provideToolFlowViolations(): iterable
|
||||
{
|
||||
yield 'tool results mixed with other content' => [
|
||||
[
|
||||
new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])),
|
||||
new SamplingMessage(Role::User, [
|
||||
new ToolResultContent('call-1', [new TextContent('18 C')]),
|
||||
new TextContent('and also...'),
|
||||
]),
|
||||
],
|
||||
'Tool results mixed with other content.',
|
||||
];
|
||||
|
||||
yield 'tool result missing' => [
|
||||
[new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', []))],
|
||||
'Tool result missing in request.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SamplingMessage[] $messages
|
||||
*/
|
||||
#[DataProvider('provideToolFlowViolations')]
|
||||
public function testHandleRejectsToolFlowViolationsWithInvalidParams(array $messages, string $expectedMessage): void
|
||||
{
|
||||
$callback = new class implements SamplingCallbackInterface {
|
||||
public bool $invoked = false;
|
||||
|
||||
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
|
||||
{
|
||||
$this->invoked = true;
|
||||
|
||||
throw new \LogicException('The callback must not run for an invalid request.');
|
||||
}
|
||||
};
|
||||
|
||||
$request = $this->requestFor($messages);
|
||||
$response = (new SamplingRequestHandler($callback))->handle($request);
|
||||
|
||||
$this->assertInstanceOf(Error::class, $response);
|
||||
$this->assertSame(Error::INVALID_PARAMS, $response->code);
|
||||
$this->assertSame($expectedMessage, $response->message);
|
||||
$this->assertSame($request->getId(), $response->id);
|
||||
$this->assertFalse($callback->invoked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SamplingMessage[] $messages
|
||||
*/
|
||||
private function requestFor(array $messages): CreateSamplingMessageRequest
|
||||
{
|
||||
return (new CreateSamplingMessageRequest($messages, 150))->withId('sampling-1');
|
||||
}
|
||||
|
||||
private function callbackReturningText(): SamplingCallbackInterface
|
||||
{
|
||||
return new class implements SamplingCallbackInterface {
|
||||
public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult
|
||||
{
|
||||
return new CreateSamplingMessageResult(Role::Assistant, new TextContent('Paris is warm.'), 'test-model');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,4 +68,47 @@ class ClientCapabilitiesTest extends TestCase
|
|||
$this->assertTrue($restored->roots);
|
||||
$this->assertTrue($restored->rootsListChanged);
|
||||
}
|
||||
|
||||
public function testRoundTripPreservesSamplingSubCapabilities(): void
|
||||
{
|
||||
$capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true);
|
||||
|
||||
$serialized = $capabilities->jsonSerialize();
|
||||
$this->assertObjectHasProperty('context', $serialized['sampling']);
|
||||
$this->assertObjectHasProperty('tools', $serialized['sampling']);
|
||||
|
||||
$restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true));
|
||||
|
||||
$this->assertTrue($restored->sampling);
|
||||
$this->assertTrue($restored->samplingContext);
|
||||
$this->assertTrue($restored->samplingTools);
|
||||
}
|
||||
|
||||
public function testPlainSamplingLeavesSubCapabilitiesOff(): void
|
||||
{
|
||||
$capabilities = new ClientCapabilities(sampling: true);
|
||||
|
||||
$serialized = $capabilities->jsonSerialize();
|
||||
$this->assertObjectNotHasProperty('context', $serialized['sampling']);
|
||||
$this->assertObjectNotHasProperty('tools', $serialized['sampling']);
|
||||
|
||||
$restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true));
|
||||
|
||||
$this->assertTrue($restored->sampling);
|
||||
$this->assertFalse($restored->samplingContext);
|
||||
$this->assertFalse($restored->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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
125
tests/Unit/Schema/Content/SamplingMessageTest.php
Normal file
125
tests/Unit/Schema/Content/SamplingMessageTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?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 SamplingMessageTest 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);
|
||||
|
||||
$this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant), true)));
|
||||
$this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user), true)));
|
||||
}
|
||||
|
||||
public function testSingleContentBlockKeepsItsShape(): void
|
||||
{
|
||||
$message = SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'text', 'text' => 'hi']]);
|
||||
|
||||
$this->assertInstanceOf(TextContent::class, $message->content);
|
||||
$this->assertSame('{"role":"user","content":{"type":"text","text":"hi"}}', json_encode($message));
|
||||
$this->assertCount(1, $message->getContentBlocks());
|
||||
}
|
||||
|
||||
public function testSingleElementListKeepsItsShape(): void
|
||||
{
|
||||
$message = SamplingMessage::fromArray(['role' => 'user', 'content' => [['type' => 'text', 'text' => 'hi']]]);
|
||||
|
||||
$this->assertIsArray($message->content);
|
||||
$this->assertSame('{"role":"user","content":[{"type":"text","text":"hi"}]}', json_encode($message));
|
||||
$this->assertCount(1, $message->getContentBlocks());
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool-flow rules span the whole message list, so a single message is never
|
||||
* rejected for carrying the "wrong" block — see CreateSamplingMessageRequestTest.
|
||||
*/
|
||||
public function testToolBlocksAreAcceptedRegardlessOfRole(): void
|
||||
{
|
||||
$message = new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', []));
|
||||
|
||||
$this->assertInstanceOf(ToolUseContent::class, $message->content);
|
||||
}
|
||||
|
||||
public function testFilteredContentStillSerializesAsAnArray(): void
|
||||
{
|
||||
$blocks = [new TextContent('thinking'), new ToolUseContent('call-1', 'weather', [])];
|
||||
|
||||
// array_filter() preserves keys, so this list starts at index 1.
|
||||
$toolUses = array_filter($blocks, static fn ($block): bool => $block instanceof ToolUseContent);
|
||||
$message = new SamplingMessage(Role::Assistant, $toolUses);
|
||||
|
||||
$this->assertSame(
|
||||
'{"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]}',
|
||||
json_encode($message),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmptyContentListIsRejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new SamplingMessage(Role::User, []);
|
||||
}
|
||||
|
||||
public function testEmptyContentListIsRejectedWhenHydrating(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
SamplingMessage::fromArray(['role' => 'user', 'content' => []]);
|
||||
}
|
||||
|
||||
public function testUnknownContentTypeIsRejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
SamplingMessage::fromArray(['role' => 'user', 'content' => ['type' => 'nope']]);
|
||||
}
|
||||
|
||||
public function testUnknownRoleIsRejected(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
/* @phpstan-ignore argument.type */
|
||||
SamplingMessage::fromArray(['role' => 'system', 'content' => ['type' => 'text', 'text' => 'hi']]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<?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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
119
tests/Unit/Schema/Content/ToolResultContentTest.php
Normal file
119
tests/Unit/Schema/Content/ToolResultContentTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?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\EmbeddedResource;
|
||||
use Mcp\Schema\Content\ResourceLink;
|
||||
use Mcp\Schema\Content\SamplingMessage;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Content\ToolResultContent;
|
||||
use Mcp\Schema\Enum\Role;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ToolResultContentTest extends TestCase
|
||||
{
|
||||
public function testRoundTrip(): void
|
||||
{
|
||||
$content = ToolResultContent::fromArray([
|
||||
'type' => 'tool_result',
|
||||
'toolUseId' => 'call-1',
|
||||
'content' => [['type' => 'text', 'text' => '21 C']],
|
||||
'structuredContent' => ['temperature' => 21],
|
||||
'isError' => true,
|
||||
'_meta' => ['provider' => 'test'],
|
||||
]);
|
||||
|
||||
$this->assertSame('tool_result', $content->type);
|
||||
$this->assertSame('call-1', $content->toolUseId);
|
||||
$this->assertSame(['temperature' => 21], $content->structuredContent);
|
||||
$this->assertTrue($content->isError);
|
||||
$this->assertSame(['provider' => 'test'], $content->meta);
|
||||
|
||||
$textContent = $content->content[0];
|
||||
$this->assertInstanceOf(TextContent::class, $textContent);
|
||||
$this->assertSame('21 C', $textContent->text);
|
||||
|
||||
$restored = ToolResultContent::fromArray(json_decode(json_encode($content), true));
|
||||
$this->assertEquals($content, $restored);
|
||||
}
|
||||
|
||||
public function testIsErrorIsOmittedWhenFalse(): void
|
||||
{
|
||||
$content = new ToolResultContent('call-1', [new TextContent('ok')]);
|
||||
|
||||
$serialized = $content->jsonSerialize();
|
||||
|
||||
$this->assertArrayNotHasKey('isError', $serialized);
|
||||
$this->assertArrayNotHasKey('structuredContent', $serialized);
|
||||
$this->assertArrayNotHasKey('_meta', $serialized);
|
||||
$this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content), true))->isError);
|
||||
}
|
||||
|
||||
public function testAcceptsEveryCallToolResultContentBlock(): void
|
||||
{
|
||||
$content = ToolResultContent::fromArray([
|
||||
'toolUseId' => 'call-1',
|
||||
'content' => [
|
||||
['type' => 'text', 'text' => 'a'],
|
||||
['type' => 'image', 'data' => base64_encode('img'), 'mimeType' => 'image/png'],
|
||||
['type' => 'audio', 'data' => base64_encode('snd'), 'mimeType' => 'audio/wav'],
|
||||
['type' => 'resource_link', 'uri' => 'file:///report.txt', 'name' => 'report'],
|
||||
['type' => 'resource', 'resource' => ['uri' => 'file:///a.txt', 'mimeType' => 'text/plain', 'text' => 'a']],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(ResourceLink::class, $content->content[3]);
|
||||
$this->assertInstanceOf(EmbeddedResource::class, $content->content[4]);
|
||||
}
|
||||
|
||||
public function testFilteredContentStillSerializesAsAnArray(): void
|
||||
{
|
||||
$blocks = [new TextContent('drop me'), new TextContent('keep me')];
|
||||
|
||||
// array_filter() preserves keys, so this list starts at index 1.
|
||||
$kept = array_filter($blocks, static fn (TextContent $block): bool => 'keep me' === $block->text);
|
||||
$content = new ToolResultContent('call-1', $kept);
|
||||
|
||||
$this->assertSame(
|
||||
'{"type":"tool_result","toolUseId":"call-1","content":[{"type":"text","text":"keep me"}]}',
|
||||
json_encode($content),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRejectsNonStandardContentBlocks(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
/* @phpstan-ignore argument.type */
|
||||
new ToolResultContent('call-1', [
|
||||
new SamplingMessage(Role::User, new TextContent('not a tool result content block')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRejectsUnsupportedContentType(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
ToolResultContent::fromArray([
|
||||
'toolUseId' => 'call-1',
|
||||
'content' => [['type' => 'tool_use', 'id' => 'x', 'name' => 'y', 'input' => []]],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRejectsMissingToolUseId(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
ToolResultContent::fromArray(['content' => []]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue