Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----

Expand Down
37 changes: 37 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
>
Expand Down
14 changes: 9 additions & 5 deletions docs/server-client-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
23 changes: 21 additions & 2 deletions src/Schema/ClientCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -38,7 +40,7 @@ public function __construct(
* roots?: array{
* listChanged?: bool,
* },
* sampling?: bool,
* sampling?: array{context?: mixed, tools?: mixed}|object,
* elicitation?: bool,
* experimental?: array<string, mixed>,
* extensions?: array<string, mixed>,
Expand All @@ -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');
}
}
Comment thread
wWzZb marked this conversation as resolved.

$elicitation = null;
Expand All @@ -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,
);
}

Expand All @@ -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) {
Expand Down
78 changes: 67 additions & 11 deletions src/Schema/Content/SamplingMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,44 @@
/**
* 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: TextContent|ImageContent|AudioContent
* content: array<string, mixed>|array<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
*/
public function __construct(
public readonly Role $role,
public readonly TextContent|ImageContent|AudioContent $content,
public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content,
Comment thread
wWzZb marked this conversation as resolved.
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');
}

Expand All @@ -47,26 +72,57 @@ 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,
);
}

/**
* @return SamplingMessageData
*/
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<string, mixed> $contentData
*
* @return SamplingContent
*/
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)),
};
}
}
99 changes: 99 additions & 0 deletions src/Schema/Content/ToolResultContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Schema\Content;

use Mcp\Exception\InvalidArgumentException;

/**
* The result of a tool use, provided by the user back to the assistant.
*/
final class ToolResultContent extends Content
{
/**
* @param Content[] $content
* @param ?array<string, mixed> $structuredContent
* @param ?array<string, mixed> $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 TextContent && !$item instanceof ImageContent && !$item instanceof AudioContent && !$item instanceof EmbeddedResource) {
throw new InvalidArgumentException('Tool result content must contain standard content blocks.');
}
}

parent::__construct('tool_result');
}

/**
* @param array<string, mixed> $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<string, mixed>
*/
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;
}
}
Loading