[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

View file

@ -14,6 +14,8 @@ namespace Mcp\Tests\Unit\Schema\Request;
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 Mcp\Schema\Enum\ToolChoiceMode;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
@ -70,4 +72,123 @@ final class CreateSamplingMessageRequestTest extends TestCase
$this->assertSame('weather', $hydrated->tools[0]->name);
$this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode);
}
public function testValidToolFlowPasses(): void
{
$this->expectNotToPerformAssertions();
$this->requestFor([
new SamplingMessage(Role::User, new TextContent('Weather in Paris and London?')),
new SamplingMessage(Role::Assistant, [
new ToolUseContent('call-1', 'weather', ['city' => 'Paris']),
new ToolUseContent('call-2', 'weather', ['city' => 'London']),
]),
new SamplingMessage(Role::User, [
new ToolResultContent('call-1', [new TextContent('18 C')]),
new ToolResultContent('call-2', [new TextContent('15 C')]),
]),
new SamplingMessage(Role::Assistant, new TextContent('Paris is warmer.')),
])->validateToolFlow();
}
public function testToolResultsMixedWithOtherContentAreRejected(): void
{
$request = $this->requestFor([
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...'),
]),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Tool results mixed with other content.');
$request->validateToolFlow();
}
public function testToolUseInUserMessageIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::User, new ToolUseContent('call-1', 'weather', [])),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('ToolUseContent is only valid in assistant sampling messages.');
$request->validateToolFlow();
}
public function testToolResultInAssistantMessageIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::Assistant, new ToolResultContent('call-1', [new TextContent('18 C')])),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('ToolResultContent is only valid in user sampling messages.');
$request->validateToolFlow();
}
public function testUnansweredToolUseIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::Assistant, [
new ToolUseContent('call-1', 'weather', []),
new ToolUseContent('call-2', 'weather', []),
]),
new SamplingMessage(Role::User, [new ToolResultContent('call-1', [new TextContent('18 C')])]),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Tool result missing in request.');
$request->validateToolFlow();
}
public function testTrailingToolUseIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Tool result missing in request.');
$request->validateToolFlow();
}
public function testToolUseFollowedByPlainMessageIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::Assistant, new ToolUseContent('call-1', 'weather', [])),
new SamplingMessage(Role::User, new TextContent('never mind')),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Tool result missing in request.');
$request->validateToolFlow();
}
public function testUnsolicitedToolResultIsRejected(): void
{
$request = $this->requestFor([
new SamplingMessage(Role::User, new ToolResultContent('call-9', [new TextContent('18 C')])),
]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Tool result "call-9" does not answer a preceding tool use.');
$request->validateToolFlow();
}
/**
* @param SamplingMessage[] $messages
*/
private function requestFor(array $messages): CreateSamplingMessageRequest
{
return new CreateSamplingMessageRequest($messages, 150);
}
}