[Schema][Server] Harden sampling tools against the spec (#420)

* harden sampling tools against the spec

Report tool-flow violations as -32602 instead of dropping the request,
accept resource_link in tool results, gate tools on sampling.tools, and
reject empty content.

* normalize keyed content arrays to lists

array_filter() and friends preserve keys, and a keyed array serializes as a
JSON object instead of the content-block array the schema requires.
This commit is contained in:
Christopher Hertel 2026-08-15 04:13:53 +02:00 • committed by GitHub
commit 43815e38a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1212 additions and 167 deletions

View file

@ -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(

View file

@ -58,8 +58,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)