Skip to content

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

Merged
chr-hertel merged 2 commits into
modelcontextprotocol:mainfrom
chr-hertel:sampling-tools-followup
Aug 15, 2026
Merged

[Schema][Server] Harden sampling tools against the spec#420
chr-hertel merged 2 commits into
modelcontextprotocol:mainfrom
chr-hertel:sampling-tools-followup

Conversation

@chr-hertel

@chr-hertel chr-hertel commented Aug 15, 2026

Copy link
Copy Markdown
Member

Follow-up to #409, now rebased onto main so the diff is only this work.

Fixes

  • Tool-flow violations are answered, not dropped. The constraint checks lived in SamplingMessage::__construct, so they fired during hydration — MessageFactory returns the resulting exception inside the message array and the client's processMessage() loop ignores anything that is not a Response|Error|Request|Notification. A violating request got no reply at all and the server blocked until timeout. They now live in CreateSamplingMessageRequest::validateToolFlow(), which the client handler runs to return -32602 and the gateway runs to fail before sending.
  • Tool-use/tool-result balance is enforced. Every ToolUseContent must be answered by a matching ToolResultContent before the conversation continues.
  • resource_link is accepted in tool results, matching CallToolResult.content as the spec requires.
  • ClientGateway::supportsSamplingTools() / supportsSamplingContext(), matching supportsSampling(). Servers must not send tools to a client that did not advertise sampling.tools, and there was no way to check.
  • Empty content is rejected on SamplingMessage and CreateSamplingMessageResult.
  • isError is omitted when false, per its schema default.

Cleanups

  • getContentBlocks() on SamplingMessage and CreateSamplingMessageResult so callers can iterate without branching on the single-vs-list union.
  • Corrected docblocks: ToolResultContent::$content claimed Content[] but rejects most of it; SamplingMessageData described the wire shape while typing jsonSerialize().
  • CHANGELOG records the two BC breaks (array-valued $content, assistant-only result role).
  • Examples and docs used end_turn, which is not a spec value.

Tests

Per-class unit tests for ToolUseContent, ToolResultContent, SamplingMessage and ToolChoice, validator coverage on the request, handler coverage for the -32602 path, and an integration test running a full multi-turn tool loop plus the capability gate.

1219 tests pass, PHPStan clean, cs-fixer clean.

@chr-hertel chr-hertel added this to the 0.8.0 milestone Aug 15, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens sampling tool flows to match MCP validation, capability, content, and serialization requirements.

Changes:

  • Validates complete tool-use/tool-result flows and returns -32602 for violations.
  • Adds sampling capability helpers, content normalization APIs, and stricter schema validation.
  • Expands unit/integration coverage and updates documentation and examples.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
CHANGELOG.md Records sampling changes and BC breaks.
docs/client.md Updates sampling stop reason.
docs/server-client-communication.md Documents capability gates and tool loops.
examples/client/http_client_communication.php Corrects stop reason.
examples/client/stdio_client_communication.php Corrects stop reason.
src/Client/Handler/Request/SamplingRequestHandler.php Returns invalid-params errors for flow violations.
src/Schema/ClientCapabilities.php Documents sampling sub-capabilities.
src/Schema/Content/SamplingMessage.php Moves flow validation and adds block access.
src/Schema/Content/ToolResultContent.php Supports resource links and omits false isError.
src/Schema/Request/CreateSamplingMessageRequest.php Implements tool-flow validation.
src/Schema/Result/CreateSamplingMessageResult.php Enforces role/content constraints and block access.
src/Server/ClientGateway.php Validates requests and exposes capability checks.
tests/Integration/Fixture/sampling_tools.php Adds sampling-tool server fixture.
tests/Integration/SamplingToolsTest.php Covers complete tool loops and capability gating.
tests/Unit/Client/Handler/Request/SamplingRequestHandlerTest.php Covers handler validation responses.
tests/Unit/Schema/ClientCapabilitiesSamplingTest.php Removes superseded capability tests.
tests/Unit/Schema/ClientCapabilitiesTest.php Consolidates sampling capability tests.
tests/Unit/Schema/Content/SamplingMessageTest.php Covers message hydration and validation.
tests/Unit/Schema/Content/SamplingToolContentTest.php Removes superseded combined tests.
tests/Unit/Schema/Content/ToolResultContentTest.php Covers tool-result schema behavior.
tests/Unit/Schema/Content/ToolUseContentTest.php Covers tool-use serialization and validation.
tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php Covers valid and invalid tool flows.
tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php Covers result constraints and shapes.
tests/Unit/Schema/ToolChoiceTest.php Covers tool-choice modes and validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

}
if (Role::Assistant === $role && $item instanceof ToolResultContent) {
throw new InvalidArgumentException('ToolResultContent is only valid in user sampling messages.');
if (\is_array($content)) {
) {
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) {
Comment on lines +51 to 61
if (\is_array($content)) {
if ([] === $content) {
throw new InvalidArgumentException('CreateSamplingMessageResult content must not be empty.');
}

foreach ($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.');
}
}
}
array_filter() and friends preserve keys, and a keyed array serializes as a
JSON object instead of the content-block array the schema requires.
@chr-hertel
chr-hertel merged commit 43815e3 into modelcontextprotocol:main Aug 15, 2026
23 checks passed
@chr-hertel
chr-hertel deleted the sampling-tools-followup branch August 15, 2026 02:13
chr-hertel added a commit to chr-hertel/php-sdk that referenced this pull request Aug 15, 2026
Ports the type definitions the 2026-07-28 revision introduces outside of
sampling tool use, which modelcontextprotocol#409 and modelcontextprotocol#420 already cover. Every addition is
optional and defaults to current behaviour, so a connection negotiated on an
older revision is unaffected.

Elicitation gains modes. ElicitationMode splits `form` — build a form from the
requested schema — from `url`, which sends the user out of band and returns
only the accept/decline/cancel outcome. That is why requestedSchema becomes
optional and `url` appears beside it. ClientCapabilities learns the matching
sub-capabilities, where an `elicitation` naming no mode declares form, the
only shape that existed before url mode.

Schemas loosen where the revision loosens them: Tool::outputSchema may
describe any JSON value rather than only an object, and
CallToolResult::structuredContent follows.

Adds the three error codes the revision defines (-32020 header mismatch,
-32021 missing required client capability, -32022 unsupported protocol
version) and switches ProtocolVersionMiddleware to the last of them, so a
rejected version carries the supported set as structured data the client can
retry from rather than only as prose.

Icon gains `theme`, Implementation gains `title`.
chr-hertel added a commit to chr-hertel/php-sdk that referenced this pull request Aug 15, 2026
…-28 surface

Ports the type definitions this SDK still misses outside of sampling tool use,
which modelcontextprotocol#409 and modelcontextprotocol#420 already cover. Every addition is optional and defaults to
current behaviour, so a connection negotiated on an older revision is
unaffected.

From 2025-11-25, elicitation gains modes. ElicitationMode splits `form` —
build a form from the requested schema — from `url`, which sends the user out
of band and returns only the accept/decline/cancel outcome. That is why
requestedSchema becomes optional and `url` appears beside it.
ClientCapabilities learns the matching sub-capabilities, where an
`elicitation` naming no mode declares form, the only shape that existed
before. Icon gains `theme` from the same revision, and Implementation gains
the `title` BaseMetadata has carried since 2025-06-18.

From 2026-07-28, schemas loosen where the revision loosens them: SEP-2106
drops the object-only restriction, so Tool::outputSchema may describe any JSON
value and CallToolResult::structuredContent follows.

The same revision defines three error codes (-32020 header mismatch, -32021
missing required client capability, -32022 unsupported protocol version).
ProtocolVersionMiddleware switches to the last of them, so a rejected version
carries the supported set as structured data the client can retry from rather
than only as prose.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants