[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
2 changed files with 1212 additions and 167 deletions

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