[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:
Christopher Hertel 2026-08-15 04:13:53 +02:00 • committed by GitHub
commit 43815e38a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1212 additions and 167 deletions

View file

@ -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
-----

View file

@ -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(

View file

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

View file

@ -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',
);
}
});

View file

@ -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',
);
}
});

View file

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

View file

@ -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,

View file

@ -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
{

View file

@ -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;
}

View file

@ -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[],

View file

@ -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.');
}

View file

@ -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).
*

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

View 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.');
}
};
}
}

View 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');
}
};
}
}

View file

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

View file

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

View 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']]);
}
}

View file

@ -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')),
]);
}
}

View 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