diff --git a/CHANGELOG.md b/CHANGELOG.md index e23af6fb..e34d7fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. * Add `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 ----- diff --git a/docs/client.md b/docs/client.md index b27b4334..962397c8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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( diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index e55f792a..1c07d7d2 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -47,8 +47,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) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index f5a39ea9..4b6e1661 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -61,7 +61,7 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 8b71cf00..28e41b6d 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -50,7 +50,7 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM role: Role::Assistant, content: new TextContent($mockResponse), model: 'mock-gpt-4', - stopReason: 'end_turn', + stopReason: 'endTurn', ); } }); diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index a00fa47c..d3da69f4 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -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 @@ public function handle(Request $request): Response|Error { \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); diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 4c7c451e..5eab9e66 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -21,7 +21,12 @@ class ClientCapabilities implements \JsonSerializable { /** * @param array $experimental - * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui) + * @param ?array $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, diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 53973aec..fc6885b0 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -17,11 +17,17 @@ /** * 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|array>, - * _meta?: array + * content: array|list>, + * _meta?: array, * } * * @author Kyrian Obikwelu @@ -29,35 +35,48 @@ class SamplingMessage extends Content { /** - * @param SamplingContent|list $content - * @param ?array $meta + * @var SamplingContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content; + + /** + * @param SamplingContent|array $content keys are discarded, the property always holds a list + * @param ?array $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.'); + } } - } - 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.'); + // 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; + parent::__construct('sampling'); } + /** + * @return list + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; + } + /** * @param SamplingMessageData $data */ @@ -66,7 +85,7 @@ public static function fromArray(array $data): self 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 @@ public static function fromArray(array $data): self } /** - * @return SamplingMessageData + * @return array{ + * role: string, + * content: SamplingContent|list, + * _meta?: array, + * } */ public function jsonSerialize(): array { diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php index a6a792d2..5e0e9b20 100644 --- a/src/Schema/Content/ToolResultContent.php +++ b/src/Schema/Content/ToolResultContent.php @@ -15,27 +15,41 @@ /** * 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 $structuredContent - * @param ?array $meta + * @var list + */ + public readonly array $content; + + /** + * @param array $content keys are discarded, the property always holds a list + * @param ?array $structuredContent + * @param ?array $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 @@ public static function fromArray(array $data): self '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 @@ public function jsonSerialize(): array '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; } diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 3f6a249c..c23b3d8e 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -13,6 +13,9 @@ 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 @@ protected static function fromParams(?array $params): static ); } + /** + * 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[], diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index fdb44618..3a50a4a6 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -29,17 +29,22 @@ class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent|ToolUseContent|list $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 $meta optional message metadata + * @var TextContent|ImageContent|AudioContent|ToolUseContent|list + */ + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content; + + /** + * @param Role $role the role of the message + * @param TextContent|ImageContent|AudioContent|ToolUseContent|array $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 $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 @@ public function __construct( 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 + */ + public function getContentBlocks(): array + { + return \is_array($this->content) ? $this->content : [$this->content]; } /** @@ -64,7 +89,7 @@ public static function fromArray(array $data): self 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.'); } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 71fe6784..445c9e06 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -163,6 +163,9 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int 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 @@ public function supportsSampling(): bool 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). * diff --git a/tests/Integration/Fixture/sampling_tools.php b/tests/Integration/Fixture/sampling_tools.php new file mode 100644 index 00000000..94cd5612 --- /dev/null +++ b/tests/Integration/Fixture/sampling_tools.php @@ -0,0 +1,78 @@ + '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()); diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php new file mode 100644 index 00000000..107f163b --- /dev/null +++ b/tests/Integration/SamplingToolsTest.php @@ -0,0 +1,135 @@ +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 $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|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 $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.'); + } + }; + } +} diff --git a/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php new file mode 100644 index 00000000..923d6692 --- /dev/null +++ b/tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php @@ -0,0 +1,114 @@ +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 + */ + 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'); + } + }; + } +} diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php deleted file mode 100644 index 4a33ec79..00000000 --- a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php +++ /dev/null @@ -1,45 +0,0 @@ -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); - } -} diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php index 3c62e00b..bee2e588 100644 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -68,4 +68,47 @@ public function testRoundTripPreservesRootsListChanged(): void $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); + } } diff --git a/tests/Unit/Schema/Content/SamplingMessageTest.php b/tests/Unit/Schema/Content/SamplingMessageTest.php new file mode 100644 index 00000000..c464575b --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingMessageTest.php @@ -0,0 +1,125 @@ + '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']]); + } +} diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php deleted file mode 100644 index c10ad4ca..00000000 --- a/tests/Unit/Schema/Content/SamplingToolContentTest.php +++ /dev/null @@ -1,76 +0,0 @@ - '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')), - ]); - } -} diff --git a/tests/Unit/Schema/Content/ToolResultContentTest.php b/tests/Unit/Schema/Content/ToolResultContentTest.php new file mode 100644 index 00000000..3c45f664 --- /dev/null +++ b/tests/Unit/Schema/Content/ToolResultContentTest.php @@ -0,0 +1,119 @@ + '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' => []]); + } +} diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php new file mode 100644 index 00000000..bf7d07b0 --- /dev/null +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -0,0 +1,80 @@ + 'tool_use', + 'id' => 'call-1', + 'name' => 'weather', + 'input' => ['city' => 'Paris'], + '_meta' => ['provider' => 'test'], + ]); + + $this->assertSame('tool_use', $content->type); + $this->assertSame('call-1', $content->id); + $this->assertSame('weather', $content->name); + $this->assertSame(['city' => 'Paris'], $content->input); + $this->assertSame(['provider' => 'test'], $content->meta); + + $this->assertSame( + '{"type":"tool_use","id":"call-1","name":"weather","input":{"city":"Paris"},"_meta":{"provider":"test"}}', + json_encode($content), + ); + } + + public function testEmptyInputSerializesAsObject(): void + { + $content = new ToolUseContent('call-1', 'ping', []); + + $this->assertSame('{"type":"tool_use","id":"call-1","name":"ping","input":{}}', json_encode($content)); + } + + public function testEmptyInputSurvivesRoundTrip(): void + { + $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', [])), true); + + $this->assertSame([], ToolUseContent::fromArray($decoded)->input); + } + + /** + * @return iterable}> + */ + public static function provideInvalidData(): iterable + { + yield 'missing id' => [['name' => 'weather', 'input' => []]]; + yield 'non-string id' => [['id' => 1, 'name' => 'weather', 'input' => []]]; + yield 'missing name' => [['id' => 'call-1', 'input' => []]]; + yield 'non-string name' => [['id' => 'call-1', 'name' => 1, 'input' => []]]; + yield 'missing input' => [['id' => 'call-1', 'name' => 'weather']]; + yield 'non-array input' => [['id' => 'call-1', 'name' => 'weather', 'input' => 'nope']]; + } + + /** + * @param array $data + */ + #[DataProvider('provideInvalidData')] + public function testInvalidDataIsRejected(array $data): void + { + $this->expectException(InvalidArgumentException::class); + + ToolUseContent::fromArray($data); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 4254336f..fb065e61 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -14,6 +14,8 @@ 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 @@ public function testToolsAndToolChoiceRoundTrip(): void $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); + } } diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php index 9366f752..41961058 100644 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -11,8 +11,10 @@ namespace Mcp\Tests\Unit\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Result\CreateSamplingMessageResult; use PHPUnit\Framework\TestCase; @@ -50,4 +52,77 @@ public function testProviderSpecificStopReasonIsPreserved(): void $this->assertSame('provider-specific', $result->stopReason); $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); } + + public function testKnownStopReasonStaysAString(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'endTurn', + ]); + + $this->assertSame('endTurn', $result->stopReason); + } + + public function testSingleContentBlockKeepsItsShape(): void + { + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + + $this->assertInstanceOf(TextContent::class, $result->content); + $this->assertCount(1, $result->getContentBlocks()); + $this->assertSame('{"type":"text","text":"Done"}', json_encode($result->jsonSerialize()['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); + $result = new CreateSamplingMessageResult(Role::Assistant, $toolUses, 'test-model'); + + $this->assertSame( + '[{"type":"tool_use","id":"call-1","name":"weather","input":{}}]', + json_encode($result->jsonSerialize()['content']), + ); + } + + public function testNonAssistantRoleIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CreateSamplingMessageResult role must be "assistant".'); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + ]); + } + + public function testEmptyContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => [], + 'model' => 'test-model', + ]); + } + + public function testToolResultContentIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'tool_result', 'toolUseId' => 'call-1', 'content' => []], + 'model' => 'test-model', + ]); + } } diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php new file mode 100644 index 00000000..66ba54da --- /dev/null +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -0,0 +1,63 @@ + + */ + public static function provideModes(): iterable + { + yield 'auto' => [ToolChoiceMode::Auto]; + yield 'required' => [ToolChoiceMode::Required]; + yield 'none' => [ToolChoiceMode::None]; + } + + #[DataProvider('provideModes')] + public function testRoundTrip(ToolChoiceMode $mode): void + { + $choice = new ToolChoice($mode); + + $this->assertSame(\sprintf('{"mode":"%s"}', $mode->value), json_encode($choice)); + $this->assertSame($mode, ToolChoice::fromArray(json_decode(json_encode($choice), true))->mode); + } + + public function testModeDefaultsToAuto(): void + { + $this->assertSame(ToolChoiceMode::Auto, (new ToolChoice())->mode); + $this->assertSame(ToolChoiceMode::Auto, ToolChoice::fromArray([])->mode); + } + + public function testUnknownModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid tool choice mode "any".'); + + ToolChoice::fromArray(['mode' => 'any']); + } + + public function testNonStringModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); + + /* @phpstan-ignore argument.type */ + ToolChoice::fromArray(['mode' => 1]); + } +}