From f6773d804b6a3d595b7dc83572f979b7b5d0c917 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/3] feat: support sampling with tools --- src/Schema/ClientCapabilities.php | 23 ++++- src/Schema/Content/SamplingMessage.php | 75 +++++++++++--- 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, 632 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 48aaa713..62215c9c 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'); } @@ -47,16 +71,22 @@ public static function fromArray(array $data): self $role = Role::from($data['role']); $contentData = $data['content']; - $contentType = $contentData['type'] ?? null; + $isSingleContent = isset($contentData['type']); + $contentItems = $isSingleContent ? [$contentData] : $contentData; + $content = []; - $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)), - }; + 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, $contentInstance); + return new self( + $role, + $isSingleContent ? $content[0] : $content, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, + ); } /** @@ -64,9 +94,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 @@ -95,6 +106,34 @@ protected static function fromParams(?array $params): static $includeContext = SamplingContext::tryFrom($params['includeContext']); } + $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'], @@ -104,6 +143,8 @@ protected static function fromParams(?array $params): static $params['temperature'] ?? null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, + $tools, + $toolChoice, ); } @@ -116,7 +157,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 @@ -150,6 +193,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 986d6291..574cee62 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.'); + } + } } /** @@ -61,16 +74,34 @@ public static function fromArray(array $data): self $role = Role::from($data['role']); $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; @@ -82,6 +113,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)), }; } @@ -89,9 +121,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 @@ -103,7 +136,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 46860715..d0d7ebb6 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -34,6 +34,8 @@ use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Tool; +use Mcp\Schema\ToolChoice; use Mcp\Server\Session\SessionInterface; /** @@ -65,6 +67,8 @@ * includeContext?: SamplingContext, * stopSequences?: string[], * metadata?: array, + * tools?: Tool[], + * toolChoice?: ToolChoice, * } * * @author Kyrian Obikwelu @@ -150,6 +154,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 991fe4f0f14a089f4c98183f9531c2979cc8eee4 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/3] address sampling tools review feedback --- CHANGELOG.md | 5 +++ docs/client.md | 37 +++++++++++++++++++ src/Schema/Content/SamplingMessage.php | 7 +++- .../Schema/ClientCapabilitiesSamplingTest.php | 13 +++++++ .../CreateSamplingMessageResultTest.php | 14 +++---- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a90..813a1310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.8.0 +----- + +* 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 494edf1f..1c83d9f7 100644 --- a/docs/client.md +++ b/docs/client.md @@ -516,6 +516,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 62215c9c..017e2442 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, @@ -108,6 +109,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 0b1cb6df86f7aff200724f7db86803353f654d73 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/3] tighten sampling tools spec compliance --- docs/server-client-communication.md | 14 +++++++++----- src/Schema/Content/ToolResultContent.php | 2 +- .../Request/CreateSamplingMessageRequest.php | 2 ++ src/Server/ClientGateway.php | 3 +++ .../Schema/Content/SamplingToolContentTest.php | 8 ++++++++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index b37ff63b..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,17 +41,21 @@ $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 The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers -to send structured log messages as notifcation to clients: +to send structured log messages as notification to clients: ```php use Mcp\Schema\Enum\LoggingLevel; 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 de57f294..88ef22a3 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 d0d7ebb6..c2f743ea 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -126,6 +126,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')), + ]); + } }