Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
2 changes: 1 addition & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 27 additions & 2 deletions docs/server-client-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion examples/client/http_client_communication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}
});
Expand Down
2 changes: 1 addition & 1 deletion examples/client/stdio_client_communication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}
});
Expand Down
9 changes: 9 additions & 0 deletions src/Client/Handler/Request/SamplingRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion src/Schema/ClientCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ class ClientCapabilities implements \JsonSerializable
{
/**
* @param array<string, mixed> $experimental
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
* @param ?array<string, mixed> $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,
Expand Down
65 changes: 44 additions & 21 deletions src/Schema/Content/SamplingMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,66 @@
/**
* 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<string, mixed>|array<array<string, mixed>>,
* _meta?: array<string, mixed>
* content: array<string, mixed>|list<array<string, mixed>>,
* _meta?: array<string, mixed>,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SamplingMessage extends Content
{
/**
* @param SamplingContent|list<SamplingContent> $content
* @param ?array<string, mixed> $meta
* @var SamplingContent|list<SamplingContent>
*/
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content;

/**
* @param SamplingContent|array<SamplingContent> $content keys are discarded, the property always holds a list
* @param ?array<string, mixed> $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<SamplingContent>
*/
public function getContentBlocks(): array
{
return \is_array($this->content) ? $this->content : [$this->content];
}

/**
* @param SamplingMessageData $data
*/
Expand All @@ -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.');
}

Expand Down Expand Up @@ -99,7 +118,11 @@ public static function fromArray(array $data): self
}

/**
* @return SamplingMessageData
* @return array{
* role: string,
* content: SamplingContent|list<SamplingContent>,
* _meta?: array<string, mixed>,
* }
*/
public function jsonSerialize(): array
{
Expand Down
31 changes: 25 additions & 6 deletions src/Schema/Content/ToolResultContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $structuredContent
* @param ?array<string, mixed> $meta
* @var list<ToolResultBlock>
*/
public readonly array $content;

/**
* @param array<ToolResultBlock> $content keys are discarded, the property always holds a list
* @param ?array<string, mixed> $structuredContent
* @param ?array<string, mixed> $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');
}

Expand All @@ -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)),
};
Expand All @@ -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;
}
Expand Down
61 changes: 61 additions & 0 deletions src/Schema/Request/CreateSamplingMessageRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand Down
Loading