From 4183b61023a6c921ba8c5282825149834ecb08cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=87=B3=E5=8D=9A?= Date: Fri, 7 Aug 2026 14:30:46 +0800 Subject: [PATCH 1/4] feat: support sampling with tools --- src/Schema/ClientCapabilities.php | 23 ++++- src/Schema/Content/SamplingMessage.php | 77 ++++++++++++--- src/Schema/Content/ToolResultContent.php | 99 +++++++++++++++++++ src/Schema/Content/ToolUseContent.php | 75 ++++++++++++++ src/Schema/Enum/SamplingStopReason.php | 20 ++++ src/Schema/Enum/ToolChoiceMode.php | 19 ++++ .../Request/CreateSamplingMessageRequest.php | 53 +++++++++- .../Result/CreateSamplingMessageResult.php | 61 +++++++++--- src/Schema/ToolChoice.php | 51 ++++++++++ src/Server/ClientGateway.php | 6 ++ .../Schema/ClientCapabilitiesSamplingTest.php | 32 ++++++ .../Content/SamplingToolContentTest.php | 68 +++++++++++++ .../CreateSamplingMessageRequestTest.php | 22 +++++ .../CreateSamplingMessageResultTest.php | 54 ++++++++++ 14 files changed, 634 insertions(+), 26 deletions(-) create mode 100644 src/Schema/Content/ToolResultContent.php create mode 100644 src/Schema/Content/ToolUseContent.php create mode 100644 src/Schema/Enum/SamplingStopReason.php create mode 100644 src/Schema/Enum/ToolChoiceMode.php create mode 100644 src/Schema/ToolChoice.php create mode 100644 tests/Unit/Schema/ClientCapabilitiesSamplingTest.php create mode 100644 tests/Unit/Schema/Content/SamplingToolContentTest.php create mode 100644 tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 3bb6081b..4c7c451e 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -30,6 +30,8 @@ public function __construct( public readonly ?bool $elicitation = null, public readonly ?array $experimental = null, public readonly ?array $extensions = null, + public readonly ?bool $samplingContext = null, + public readonly ?bool $samplingTools = null, ) { } @@ -38,7 +40,7 @@ public function __construct( * roots?: array{ * listChanged?: bool, * }, - * sampling?: bool, + * sampling?: array{context?: mixed, tools?: mixed}|object, * elicitation?: bool, * experimental?: array, * extensions?: array, @@ -57,8 +59,17 @@ public static function fromArray(array $data): self } $sampling = null; + $samplingContext = null; + $samplingTools = null; if (isset($data['sampling'])) { $sampling = true; + if (\is_array($data['sampling'])) { + $samplingContext = isset($data['sampling']['context']); + $samplingTools = isset($data['sampling']['tools']); + } elseif (\is_object($data['sampling'])) { + $samplingContext = property_exists($data['sampling'], 'context'); + $samplingTools = property_exists($data['sampling'], 'tools'); + } } $elicitation = null; @@ -73,6 +84,8 @@ public static function fromArray(array $data): self $elicitation, \is_array($data['experimental'] ?? null) ? $data['experimental'] : null, \is_array($data['extensions'] ?? null) ? $data['extensions'] : null, + $samplingContext, + $samplingTools, ); } @@ -95,8 +108,14 @@ public function jsonSerialize(): array|object } } - if ($this->sampling) { + if ($this->sampling || $this->samplingContext || $this->samplingTools) { $data['sampling'] = new \stdClass(); + if ($this->samplingContext) { + $data['sampling']->context = new \stdClass(); + } + if ($this->samplingTools) { + $data['sampling']->tools = new \stdClass(); + } } if ($this->elicitation) { diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 18c8877b..de2e9c24 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -19,17 +19,41 @@ * * @phpstan-type SamplingMessageData = array{ * role: 'user'|'assistant', - * content: TextContent|ImageContent|AudioContent + * content: array|array>, + * _meta?: array * } * * @author Kyrian Obikwelu */ class SamplingMessage extends Content { + /** + * @param TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|list $content + * @param ?array $meta + */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly 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 (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.'); + } + } + + 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.'); + } + parent::__construct('sampling'); } @@ -51,18 +75,26 @@ public static function fromArray(array $data): self $contentData = $data['content']; $contentType = $contentData['type'] ?? null; - if (!\is_string($contentType)) { + if (null !== $contentType && !\is_string($contentType)) { throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.'); } - $contentInstance = match ($contentType) { - 'text' => TextContent::fromArray($contentData), - 'image' => ImageContent::fromArray($contentData), - 'audio' => AudioContent::fromArray($contentData), - default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), - }; + $isSingleContent = null !== $contentType; + $contentItems = $isSingleContent ? [$contentData] : $contentData; + $content = []; - return new self($role, $contentInstance); + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in SamplingMessage data.'); + } + $content[] = self::hydrateContent($item); + } + + return new self( + $role, + $isSingleContent ? $content[0] : $content, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** @@ -70,9 +102,32 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - return [ + $data = [ 'role' => $this->role->value, 'content' => $this->content, ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } + + /** + * @param array $contentData + */ + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent + { + $contentType = $contentData['type'] ?? null; + + return match ($contentType) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), + 'tool_result' => ToolResultContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for SamplingMessage.', $contentType)), + }; } } diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php new file mode 100644 index 00000000..2d0a8c61 --- /dev/null +++ b/src/Schema/Content/ToolResultContent.php @@ -0,0 +1,99 @@ + $structuredContent + * @param ?array $meta + */ + public function __construct( + public readonly string $toolUseId, + public readonly array $content, + public readonly ?array $structuredContent = null, + public readonly bool $isError = false, + public readonly ?array $meta = null, + ) { + foreach ($content as $item) { + if (!$item instanceof Content || $item instanceof self || $item instanceof ToolUseContent) { + throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); + } + } + + parent::__construct('tool_result'); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['toolUseId']) || !\is_string($data['toolUseId'])) { + throw new InvalidArgumentException('Missing or invalid "toolUseId" in ToolResultContent data.'); + } + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in ToolResultContent data.'); + } + + $content = []; + foreach ($data['content'] as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in ToolResultContent data.'); + } + + $content[] = match ($item['type'] ?? null) { + 'text' => TextContent::fromArray($item), + 'image' => ImageContent::fromArray($item), + 'audio' => AudioContent::fromArray($item), + 'resource' => EmbeddedResource::fromArray($item), + default => throw new InvalidArgumentException(\sprintf('Unsupported tool result content type "%s".', $item['type'] ?? null)), + }; + } + + return new self( + $data['toolUseId'], + $content, + isset($data['structuredContent']) && \is_array($data['structuredContent']) ? $data['structuredContent'] : null, + isset($data['isError']) && true === $data['isError'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'toolUseId' => $this->toolUseId, + 'content' => $this->content, + 'isError' => $this->isError, + ]; + + if (null !== $this->structuredContent) { + $data['structuredContent'] = $this->structuredContent; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php new file mode 100644 index 00000000..6acdc439 --- /dev/null +++ b/src/Schema/Content/ToolUseContent.php @@ -0,0 +1,75 @@ + $input + * @param ?array $meta + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $input, + public readonly ?array $meta = null, + ) { + parent::__construct('tool_use'); + } + + /** + * @param array{id?: mixed, name?: mixed, input?: mixed, _meta?: mixed} $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['id']) || !\is_string($data['id'])) { + throw new InvalidArgumentException('Missing or invalid "id" in ToolUseContent data.'); + } + if (!isset($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Missing or invalid "name" in ToolUseContent data.'); + } + if (!isset($data['input']) || !\is_array($data['input'])) { + throw new InvalidArgumentException('Missing or invalid "input" in ToolUseContent data.'); + } + + return new self( + $data['id'], + $data['name'], + $data['input'], + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); + } + + /** + * @return array{type: 'tool_use', id: string, name: string, input: array|\stdClass, _meta?: array} + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'id' => $this->id, + 'name' => $this->name, + 'input' => $this->input ?: new \stdClass(), + ]; + + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Enum/SamplingStopReason.php b/src/Schema/Enum/SamplingStopReason.php new file mode 100644 index 00000000..540a1dab --- /dev/null +++ b/src/Schema/Enum/SamplingStopReason.php @@ -0,0 +1,20 @@ + $metadata Optional metadata to pass through to the LLM provider. The format of * this metadata is provider-specific. + * @param ?Tool[] $tools tools that the model may use during generation + * @param ?ToolChoice $toolChoice controls how the model uses tools */ public function __construct( public readonly array $messages, @@ -51,12 +55,19 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, + public readonly ?array $tools = null, + public readonly ?ToolChoice $toolChoice = null, ) { foreach ($this->messages as $message) { if (!$message instanceof SamplingMessage) { throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); } } + foreach ($this->tools ?? [] as $tool) { + if (!$tool instanceof Tool) { + throw new InvalidArgumentException('Tools must be instances of Tool.'); + } + } } public static function getMethod(): string @@ -122,6 +133,34 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); } + $tools = null; + if (isset($params['tools'])) { + if (!\is_array($params['tools'])) { + throw new InvalidArgumentException('Invalid "tools" parameter for sampling/createMessage.'); + } + $tools = []; + foreach ($params['tools'] as $toolData) { + if ($toolData instanceof Tool) { + $tools[] = $toolData; + } elseif (\is_array($toolData)) { + $tools[] = Tool::fromArray($toolData); + } else { + throw new InvalidArgumentException('Invalid tool format in sampling/createMessage.'); + } + } + } + + $toolChoice = null; + if (isset($params['toolChoice'])) { + if ($params['toolChoice'] instanceof ToolChoice) { + $toolChoice = $params['toolChoice']; + } elseif (\is_array($params['toolChoice'])) { + $toolChoice = ToolChoice::fromArray($params['toolChoice']); + } else { + throw new InvalidArgumentException('Invalid "toolChoice" parameter for sampling/createMessage.'); + } + } + return new self( $messages, $params['maxTokens'], @@ -131,6 +170,8 @@ protected static function fromParams(?array $params): static isset($params['temperature']) ? (float) $params['temperature'] : null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, + $tools, + $toolChoice, ); } @@ -143,7 +184,9 @@ protected static function fromParams(?array $params): static * includeContext?: string, * temperature?: float, * stopSequences?: string[], - * metadata?: array + * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } */ protected function getParams(): array @@ -177,6 +220,14 @@ protected function getParams(): array $params['metadata'] = $this->metadata; } + if (null !== $this->tools) { + $params['tools'] = $this->tools; + } + + if (null !== $this->toolChoice) { + $params['toolChoice'] = $this->toolChoice; + } + return $params; } } diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 8eb4b134..d571c824 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -15,7 +15,9 @@ use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingStopReason; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -28,17 +30,28 @@ class CreateSamplingMessageResult implements ResultInterface { /** - * @param Role $role the role of the message - * @param TextContent|ImageContent|AudioContent $content the content of the message - * @param string $model the name of the model that generated the message - * @param string|null $stopReason the reason why sampling stopped, if known + * @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 SamplingStopReason|string|null $stopReason the reason why sampling stopped, if known + * @param ?array $meta optional message metadata */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent $content, + public readonly TextContent|ImageContent|AudioContent|ToolUseContent|array $content, public readonly string $model, - public readonly ?string $stopReason = null, + public readonly SamplingStopReason|string|null $stopReason = null, + public readonly ?array $meta = null, ) { + if (Role::Assistant !== $role) { + 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.'); + } + } } /** @@ -64,16 +77,34 @@ public static function fromArray(array $data): self $contentPayload = $data['content']; - $content = self::hydrateContent($contentPayload); - $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; + $isSingleContent = isset($contentPayload['type']); + $contentItems = $isSingleContent ? [$contentPayload] : $contentPayload; + $content = []; + foreach ($contentItems as $item) { + if (!\is_array($item)) { + throw new InvalidArgumentException('Invalid content block in CreateSamplingMessageResult data.'); + } + $content[] = self::hydrateContent($item); + } + + $stopReason = null; + if (isset($data['stopReason']) && \is_string($data['stopReason'])) { + $stopReason = SamplingStopReason::tryFrom($data['stopReason']) ?? $data['stopReason']; + } - return new self($role, $content, $data['model'], $stopReason); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + $data['model'], + $stopReason, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** * @param array $contentData */ - private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent { $type = $contentData['type'] ?? null; @@ -85,6 +116,7 @@ private static function hydrateContent(array $contentData): TextContent|ImageCon 'text' => TextContent::fromArray($contentData), 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), + 'tool_use' => ToolUseContent::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), }; } @@ -92,9 +124,10 @@ private static function hydrateContent(array $contentData): TextContent|ImageCon /** * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent, + * content: TextContent|ImageContent|AudioContent|ToolUseContent|list, * model: string, * stopReason?: string, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -106,7 +139,11 @@ public function jsonSerialize(): array ]; if (null !== $this->stopReason) { - $result['stopReason'] = $this->stopReason; + $result['stopReason'] = $this->stopReason instanceof SamplingStopReason ? $this->stopReason->value : $this->stopReason; + } + + if (null !== $this->meta) { + $result['_meta'] = $this->meta; } return $result; diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php new file mode 100644 index 00000000..4b55bac3 --- /dev/null +++ b/src/Schema/ToolChoice.php @@ -0,0 +1,51 @@ + $this->mode->value]; + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 5205a613..79eea55b 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -36,6 +36,8 @@ use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ListRootsResult; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use Mcp\Server\Session\SessionInterface; /** @@ -67,6 +69,8 @@ * includeContext?: SamplingContext, * stopSequences?: string[], * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } * * @author Kyrian Obikwelu @@ -152,6 +156,8 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int temperature: $options['temperature'] ?? null, stopSequences: $options['stopSequences'] ?? null, metadata: $options['metadata'] ?? null, + tools: $options['tools'] ?? null, + toolChoice: $options['toolChoice'] ?? null, ); $response = $this->request($request, $timeout); diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php new file mode 100644 index 00000000..7cd97f7a --- /dev/null +++ b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php @@ -0,0 +1,32 @@ +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); + } +} diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php new file mode 100644 index 00000000..06a4875c --- /dev/null +++ b/tests/Unit/Schema/Content/SamplingToolContentTest.php @@ -0,0 +1,68 @@ + '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'), + ]); + } +} diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index 57f790a7..4254336f 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -15,7 +15,10 @@ use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\ToolChoiceMode; use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use PHPUnit\Framework\TestCase; final class CreateSamplingMessageRequestTest extends TestCase @@ -48,4 +51,23 @@ public function testConstructorWithInvalidSetOfMessages(): void /* @phpstan-ignore argument.type */ new CreateSamplingMessageRequest($messages, 150); } + + public function testToolsAndToolChoiceRoundTrip(): void + { + $tool = new Tool('weather', null, ['type' => 'object', 'properties' => [], 'required' => null], 'Get weather', null); + $request = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Weather in Paris?'))], + 150, + tools: [$tool], + toolChoice: new ToolChoice(ToolChoiceMode::Required), + ); + + $payload = $request->withId(1)->jsonSerialize(); + $this->assertSame('weather', $payload['params']['tools'][0]->name); + $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); + + $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertSame('weather', $hydrated->tools[0]->name); + $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); + } } diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php new file mode 100644 index 00000000..90949134 --- /dev/null +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -0,0 +1,54 @@ + 'assistant', + 'content' => [ + ['type' => 'text', 'text' => 'Checking weather.'], + ['type' => 'tool_use', 'id' => 'call-1', 'name' => 'weather', 'input' => ['city' => 'Paris']], + ], + 'model' => 'test-model', + 'stopReason' => 'toolUse', + '_meta' => ['traceId' => 'trace-1'], + ]); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); + $this->assertSame(SamplingStopReason::ToolUse, $result->stopReason); + $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); + $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); + } + + public function testProviderSpecificStopReasonIsPreserved(): void + { + $result = new CreateSamplingMessageResult( + Role::Assistant, + new TextContent('Done'), + 'test-model', + 'provider-specific', + ); + + $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); + } +} From 5cf947ee4b43a0d5fa8c28487ab69ff14b3e5a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=87=B3=E5=8D=9A?= Date: Tue, 11 Aug 2026 18:38:14 +0800 Subject: [PATCH 2/4] address sampling tools review feedback --- CHANGELOG.md | 1 + docs/client.md | 37 +++++++++++++++++++ src/Schema/Content/SamplingMessage.php | 7 +++- .../Schema/ClientCapabilitiesSamplingTest.php | 13 +++++++ .../CreateSamplingMessageResultTest.php | 14 +++---- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c1801..e23af6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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. 0.7.0 ----- diff --git a/docs/client.md b/docs/client.md index 78059dfc..f7a1ee01 100644 --- a/docs/client.md +++ b/docs/client.md @@ -551,6 +551,43 @@ $client = Client::builder() ->build(); ``` +#### Sampling with Tools + +Clients that support tool-enabled sampling should advertise that capability and forward the request's `tools` and +`toolChoice` fields to their LLM provider. A provider response that requests tools can be returned as one or more +`ToolUseContent` blocks: + +```php +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingStopReason; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$client = Client::builder() + ->setCapabilities(new ClientCapabilities( + sampling: true, + samplingContext: true, + samplingTools: true, + )) + ->addRequestHandler(new SamplingRequestHandler($samplingCallback)) + ->build(); + +// Inside the sampling callback, after invoking the LLM provider: +return new CreateSamplingMessageResult( + role: Role::Assistant, + content: array_map( + static fn ($call) => new ToolUseContent($call->id, $call->name, $call->input), + $providerResponse->toolCalls, + ), + model: $providerResponse->model, + stopReason: SamplingStopReason::ToolUse, +); +``` + +The server executes the requested tools and sends their results in a later sampling request as `ToolResultContent` +blocks in a user message. The client should pass those blocks back to the LLM provider to continue the sampling loop. + > [!IMPORTANT] > **Error Handling in Sampling Callbacks:** > diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index de2e9c24..53973aec 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -17,6 +17,7 @@ /** * Describes a message issued to or received from an LLM API during sampling. * + * @phpstan-type SamplingContent TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent * @phpstan-type SamplingMessageData = array{ * role: 'user'|'assistant', * content: array|array>, @@ -28,8 +29,8 @@ class SamplingMessage extends Content { /** - * @param TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|list $content - * @param ?array $meta + * @param SamplingContent|list $content + * @param ?array $meta */ public function __construct( public readonly Role $role, @@ -116,6 +117,8 @@ public function jsonSerialize(): array /** * @param array $contentData + * + * @return SamplingContent */ private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent { diff --git a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php index 7cd97f7a..4a33ec79 100644 --- a/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesSamplingTest.php @@ -29,4 +29,17 @@ public function testSamplingSubCapabilitiesRoundTrip(): void $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/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php index 90949134..0fcb8499 100644 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -13,7 +13,6 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\ToolUseContent; -use Mcp\Schema\Enum\Role; use Mcp\Schema\Enum\SamplingStopReason; use Mcp\Schema\Result\CreateSamplingMessageResult; use PHPUnit\Framework\TestCase; @@ -42,13 +41,14 @@ public function testArrayContentAndKnownStopReasonAreHydrated(): void public function testProviderSpecificStopReasonIsPreserved(): void { - $result = new CreateSamplingMessageResult( - Role::Assistant, - new TextContent('Done'), - 'test-model', - 'provider-specific', - ); + $result = CreateSamplingMessageResult::fromArray([ + 'role' => 'assistant', + 'content' => ['type' => 'text', 'text' => 'Done'], + 'model' => 'test-model', + 'stopReason' => 'provider-specific', + ]); + $this->assertSame('provider-specific', $result->stopReason); $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); } } From fc5adcf2ce6fbb3efa8301c522cbae5ed333f7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=87=B3=E5=8D=9A?= Date: Tue, 11 Aug 2026 18:46:52 +0800 Subject: [PATCH 3/4] tighten sampling tools spec compliance --- docs/server-client-communication.md | 12 ++++++++---- src/Schema/Content/ToolResultContent.php | 2 +- src/Schema/Request/CreateSamplingMessageRequest.php | 2 ++ src/Server/ClientGateway.php | 3 +++ .../Unit/Schema/Content/SamplingToolContentTest.php | 8 ++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index f54294bc..e55f792a 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -32,7 +32,7 @@ class MyService ## Sampling -With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to +With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: ```php @@ -41,12 +41,16 @@ $result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temper The `sample` method accepts four arguments: -1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SampleMessage` instances. +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SamplingMessage` instances. 2. `maxTokens`, which defaults to `1000` 3. `timeout` in seconds, which defaults to `120` -4. `options` which might include `system_prompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` +4. `options` which might include `systemPrompt`, `preferences` for model choice, `includeContext`, `temperature`, + `stopSequences`, `metadata`, `tools`, and `toolChoice` -[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) +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. + +[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#protocol-messages) ## Logging diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php index 2d0a8c61..a6a792d2 100644 --- a/src/Schema/Content/ToolResultContent.php +++ b/src/Schema/Content/ToolResultContent.php @@ -31,7 +31,7 @@ public function __construct( public readonly ?array $meta = null, ) { foreach ($content as $item) { - if (!$item instanceof Content || $item instanceof self || $item instanceof ToolUseContent) { + if (!$item instanceof TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof EmbeddedResource) { throw new InvalidArgumentException('Tool result content must contain standard content blocks.'); } } diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index c64c9bb1..3f6a249c 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -39,6 +39,8 @@ final class CreateSamplingMessageRequest extends Request * @param ?SamplingContext $includeContext A request to include context from one or more MCP servers (including * the caller), to be attached to the prompt. The client MAY ignore this request. * Allowed values: "none", "thisServer", "allServers" + * Values other than "none" are soft-deprecated and SHOULD only be sent + * when the client advertises the sampling.context capability. * @param ?float $temperature The temperature to use for sampling. The client MAY ignore this request. * @param ?string[] $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 79eea55b..71fe6784 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -128,6 +128,9 @@ public function progress(float $progress, ?float $total = null, ?string $message * @param int $maxTokens Maximum tokens to generate * @param int $timeout The timeout in seconds * @param SampleOptions $options Additional sampling options (temperature, etc.) + * Context values other than `none` require the client's + * sampling.context capability; tools and toolChoice require + * the client's sampling.tools capability. * * @return CreateSamplingMessageResult The sampling response * diff --git a/tests/Unit/Schema/Content/SamplingToolContentTest.php b/tests/Unit/Schema/Content/SamplingToolContentTest.php index 06a4875c..c10ad4ca 100644 --- a/tests/Unit/Schema/Content/SamplingToolContentTest.php +++ b/tests/Unit/Schema/Content/SamplingToolContentTest.php @@ -65,4 +65,12 @@ public function testToolResultCannotBeMixedWithOtherContent(): void 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')), + ]); + } } From cc74cc838c0e8ae7fce8f0a881f57bb40327b48a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:14:43 +0200 Subject: [PATCH 4/4] drop SamplingStopReason enum in favor of open string The spec leaves stopReason open for provider-specific values, so upcasting the four known ones to enum cases makes every future enum addition a silent BC break for string comparisons. --- docs/client.md | 3 +-- src/Schema/Enum/SamplingStopReason.php | 20 ------------------- .../Result/CreateSamplingMessageResult.php | 14 ++++++------- .../CreateSamplingMessageResultTest.php | 3 +-- 4 files changed, 8 insertions(+), 32 deletions(-) delete mode 100644 src/Schema/Enum/SamplingStopReason.php diff --git a/docs/client.md b/docs/client.md index f7a1ee01..b27b4334 100644 --- a/docs/client.md +++ b/docs/client.md @@ -561,7 +561,6 @@ Clients that support tool-enabled sampling should advertise that capability and use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\ToolUseContent; use Mcp\Schema\Enum\Role; -use Mcp\Schema\Enum\SamplingStopReason; use Mcp\Schema\Result\CreateSamplingMessageResult; $client = Client::builder() @@ -581,7 +580,7 @@ return new CreateSamplingMessageResult( $providerResponse->toolCalls, ), model: $providerResponse->model, - stopReason: SamplingStopReason::ToolUse, + stopReason: 'toolUse', ); ``` diff --git a/src/Schema/Enum/SamplingStopReason.php b/src/Schema/Enum/SamplingStopReason.php deleted file mode 100644 index 540a1dab..00000000 --- a/src/Schema/Enum/SamplingStopReason.php +++ /dev/null @@ -1,20 +0,0 @@ - $content the content of the message * @param string $model the name of the model that generated the message - * @param SamplingStopReason|string|null $stopReason the reason why sampling stopped, if known + * @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, public readonly string $model, - public readonly SamplingStopReason|string|null $stopReason = null, + public readonly ?string $stopReason = null, public readonly ?array $meta = null, ) { if (Role::Assistant !== $role) { @@ -87,10 +88,7 @@ public static function fromArray(array $data): self $content[] = self::hydrateContent($item); } - $stopReason = null; - if (isset($data['stopReason']) && \is_string($data['stopReason'])) { - $stopReason = SamplingStopReason::tryFrom($data['stopReason']) ?? $data['stopReason']; - } + $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; return new self( $role, @@ -139,7 +137,7 @@ public function jsonSerialize(): array ]; if (null !== $this->stopReason) { - $result['stopReason'] = $this->stopReason instanceof SamplingStopReason ? $this->stopReason->value : $this->stopReason; + $result['stopReason'] = $this->stopReason; } if (null !== $this->meta) { diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php index 0fcb8499..9366f752 100644 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -13,7 +13,6 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\ToolUseContent; -use Mcp\Schema\Enum\SamplingStopReason; use Mcp\Schema\Result\CreateSamplingMessageResult; use PHPUnit\Framework\TestCase; @@ -34,7 +33,7 @@ public function testArrayContentAndKnownStopReasonAreHydrated(): void $this->assertInstanceOf(TextContent::class, $result->content[0]); $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); - $this->assertSame(SamplingStopReason::ToolUse, $result->stopReason); + $this->assertSame('toolUse', $result->stopReason); $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); }