[Client][Schema][Server] Close the 2025-11-25 schema gaps, add the 2026-07-28 surface - #1
Closed
chr-hertel wants to merge 10 commits into
Closed
[Client][Schema][Server] Close the 2025-11-25 schema gaps, add the 2026-07-28 surface#1chr-hertel wants to merge 10 commits into
chr-hertel wants to merge 10 commits into
Conversation
chr-hertel
force-pushed
the
feature/protocol-version-negotiation
branch
from
July 27, 2026 15:18
1a6023e to
6042d3d
Compare
chr-hertel
force-pushed
the
feature/schema-2026-07-28-drift
branch
2 times, most recently
from
July 27, 2026 15:43
b4d89d9 to
02fefc3
Compare
chr-hertel
marked this pull request as ready for review
July 27, 2026 15:49
chr-hertel
force-pushed
the
feature/protocol-version-negotiation
branch
from
August 14, 2026 21:52
6042d3d to
8e9d08c
Compare
chr-hertel
force-pushed
the
feature/schema-2026-07-28-drift
branch
from
August 15, 2026 00:30
02fefc3 to
004c8ce
Compare
chr-hertel
changed the base branch from
feature/protocol-version-negotiation
to
main
August 15, 2026 00:30
There was a problem hiding this comment.
Pull request overview
Closes protocol-schema gaps and adds newer sampling, elicitation, metadata, error, and structured-content surfaces.
Changes:
- Adds sampling tool-use content and capability modeling.
- Adds elicitation modes, icon themes, and implementation titles.
- Introduces new error codes and relaxed output schemas with tests.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
tests/Unit/Schema/Request/SamplingToolUseTest.php |
Tests sampling tools and choices. |
tests/Unit/Schema/NonObjectOutputSchemaTest.php |
Tests relaxed output schemas. |
tests/Unit/Schema/JsonRpc/ErrorCodesTest.php |
Tests new error codes. |
tests/Unit/Schema/IconTest.php |
Tests icon themes. |
tests/Unit/Schema/ElicitationModeTest.php |
Tests elicitation modes. |
tests/Unit/Schema/Content/ToolUseContentTest.php |
Tests tool-use blocks. |
tests/Unit/Schema/Content/ToolResultContentTest.php |
Tests tool-result blocks. |
tests/Unit/Schema/Content/SamplingMessageTest.php |
Tests multi-block sampling messages. |
tests/Unit/Schema/ClientCapabilitiesTest.php |
Tests capability subfields. |
src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php |
Returns structured version errors. |
src/Schema/ToolChoice.php |
Models tool selection. |
src/Schema/Tool.php |
Relaxes output-schema roots. |
src/Schema/Result/CreateSamplingMessageResult.php |
Supports tool-use result blocks. |
src/Schema/Result/CallToolResult.php |
Widens structured content. |
src/Schema/Request/ElicitRequest.php |
Adds form and URL modes. |
src/Schema/Request/CreateSamplingMessageRequest.php |
Adds tools and tool choice. |
src/Schema/JsonRpc/Error.php |
Adds protocol error factories. |
src/Schema/Implementation.php |
Adds display titles. |
src/Schema/Icon.php |
Adds icon themes. |
src/Schema/Enum/ToolChoiceMode.php |
Defines tool-choice modes. |
src/Schema/Enum/IconTheme.php |
Defines icon themes. |
src/Schema/Enum/ElicitationMode.php |
Defines elicitation modes. |
src/Schema/Content/ToolUseContent.php |
Implements tool-use content. |
src/Schema/Content/ToolResultContent.php |
Implements tool-result content. |
src/Schema/Content/SamplingMessage.php |
Supports content-block lists. |
src/Schema/ClientCapabilities.php |
Models nested capabilities. |
Suppressed comments (1)
src/Schema/Tool.php:32
- This closed array shape still excludes valid non-object schemas that the runtime now accepts, including
items,oneOf,enum, andconst. PHPStan users will get errors for the newly supported public API. Model this as an arbitrary JSON Schema object instead of a partial sealed shape.
* type?: string,
* properties?: array<string, mixed>|\stdClass,
* required?: string[]|null,
* additionalProperties?: bool|array<string, mixed>|\stdClass,
* description?: string
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+46
to
+49
| * @param ?Tool[] $tools Tools the model may use during generation. The client MUST error if | ||
| * this is provided without declaring the `sampling.tools` capability. | ||
| * @param ?ToolChoice $toolChoice How the model should use the given tools. Same capability | ||
| * requirement as $tools. Defaults to `auto` on the client side. |
Comment on lines
+60
to
+61
| public readonly ?array $tools = null, | ||
| public readonly ?ToolChoice $toolChoice = null, |
Comment on lines
+38
to
+40
| public function __construct( | ||
| public readonly Role $role, | ||
| public readonly TextContent|ImageContent|AudioContent $content, | ||
| public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, |
Comment on lines
45
to
49
| public function __construct( | ||
| public readonly Role $role, | ||
| public readonly TextContent|ImageContent|AudioContent $content, | ||
| public readonly TextContent|ImageContent|AudioContent|ToolUseContent|ToolResultContent|array $content, | ||
| public readonly string $model, | ||
| public readonly ?string $stopReason = null, |
Comment on lines
+63
to
+65
| public static function forUrl(string $message, string $url): self | ||
| { | ||
| return new self($message, null, ElicitationMode::Url, $url); |
Comment on lines
+63
to
+65
| public static function forUrl(string $message, string $url): self | ||
| { | ||
| return new self($message, null, ElicitationMode::Url, $url); |
Comment on lines
+36
to
+38
| if (!isset($data['mode'])) { | ||
| return new self(); | ||
| } |
Comment on lines
32
to
+35
| public readonly ?string $description = null, | ||
| public readonly ?array $icons = null, | ||
| public readonly ?string $websiteUrl = null, | ||
| public readonly ?string $title = null, |
Comment on lines
157
to
158
| if ($this->structuredContent) { | ||
| $result['structuredContent'] = $this->structuredContent; |
…ocol#413) * [Client] Implement setMaxRetries connection retries * [Client] Reject a negative retry count in Configuration * [Client] Use a positive init timeout in the retry test * [Client] Drop redundant docblock note on setMaxRetries * [Client] Clear the initialized flag on a failed connection * [Client] Trim comments to the non-obvious ones * [Client] Cover retries and timeout cleanup with integration tests The retry fixture counts the processes it was started as, which is what separates a real retry from a second call into the same server. The timeout fixture outlives the client's patience and then goes idle, so the next request runs on a connection the client already gave up on once.
…col#409) * feat: support sampling with tools * address sampling tools review feedback * tighten sampling tools spec compliance * drop SamplingStopReason enum in favor of open string The spec leaves stopReason open for provider-specific values, so upcasting the four known ones to enum cases makes every future enum addition a silent BC break for string comparisons. --------- Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
…col#417) * Address review feedback on resource_link Three follow-ups from review of the resource_link work: * PromptResultFormatter: add a regression test proving the optional fields of a typed resource_link block (title, description, mimeType, size, annotations, _meta) survive formatting. The delegation to ResourceLink::fromArray() already landed while rebasing onto the PromptResultFormatter refactor, but the existing test only supplied uri/name and so could not catch a reintroduced field drop. * ResourceLink::fromArray(): validate optional fields consistently, the way the equivalent ResourceDefinition::fromArray() already does. description, mimeType and size now raise InvalidArgumentException instead of surfacing a TypeError (or silently coercing, for size's (int) cast); annotations goes through Annotations::tryFromArray(), which carries the is_array() guard that icons already had; icons uses Icon::listFromArray() so a non-array entry is reported in context. * ToolReference::extractStructuredContent(): never emit a list as structuredContent. The Content guard added earlier fixed only one instance of the real invariant - structuredContent must be a JSON object, and a PHP list can never be one. Tool::fromArray() already enforces the matching rule by rejecting an outputSchema whose type is not "object", so this aligns the runtime path with it. The test asserting the opposite for an array-typed outputSchema contradicted the phpstan type and the fromArray() check introduced alongside it in the same commit, so it is replaced by tests for the two list shapes. * Apply the structuredContent object rule to object results too `extractStructuredContent()` guards raw array results against being emitted as a JSON array, but the object branch handed back whatever `json_decode()` produced. A `JsonSerializable` returning a list or a scalar slipped straight through, producing exactly the `structuredContent` the array guard exists to prevent — and a return value that contradicts the method's own `array<string, mixed>|null` signature. Check the decoded value before returning it, and cover the object branch, which had no tests at all. * Document structured tool output `outputSchema` and `structuredContent` were undocumented: the tool return value docs covered only the `content` side, and the schema generation section is about tool parameters. Add a "Structured Output" subsection covering how to declare the schema, which return values populate `structuredContent`, and why a list has to be wrapped in a key to get structured output at all. * Gate structuredContent on the negotiated protocol revision SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value; earlier revisions require an object. Resolve the revision per request and apply the matching rule, and warn when a declared outputSchema yields none. * Resolve the protocol revision on RequestContext Keeps the `_meta`-then-session lookup in one place instead of per handler, and exposes the negotiated revision to tool handlers. * Document reading the negotiated revision from RequestContext * Warn when a self-built CallToolResult carries an invalid structuredContent Returning a CallToolResult opts out of the extraction rules, so the value is still sent unchanged — but a JSON array is not valid before SEP-2106.
…protocol#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.
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`.
`Implementation::title` reached the typed constructor unchecked, so malformed
wire data raised a TypeError instead of InvalidArgumentException.
`ToolUseContent::input` accepted a list and serialized it as a JSON array,
where the protocol requires an object. The empty array stays exempt: it is
also an empty map and still emits `{}`.
`ToolChoice` and `ElicitRequest` read their mode with isset(), which is false
for an explicit null, so `{"mode": null}` silently became the default instead
of being rejected. Both use array_key_exists() now, letting the existing type
check refuse null.
…ders `Implementation::title` could be parsed but never sent: neither `Client\Builder::setClientInfo()` nor `Server\Builder::setServerInfo()` accepted one, so every SDK user emitted null. Both gain a trailing optional `$title`. On the server it sits where the Implementation constructor already puts it, so existing positional calls keep their meaning; the client builder forwards it by name, leaving the icons and websiteUrl slots defaulted.
The object-only hydration guard was never that: `!is_array()` admitted `[1, 2, 3]` and `[]`, which serialize to JSON arrays, while rejecting the scalars 2026-07-28 permits. The truthiness emission gate was backwards in the same way — it dropped `[]`, `0`, `false` and `""`, yet emitted lists, strings and an empty stdClass. Hydration now accepts any JSON value, and `null` alone means absent, matching `ToolResultContent` which already carries this field that way. Which values a given revision permits is a question for version-aware serialization, which results cannot answer yet.
`ElicitRequest::forUrl()` built a request no SDK user could send: the only public gateway method always constructed form mode from an ElicitationSchema, and `request()` is private. `elicitUrl()` joins `elicit()`, and both funnel through one send path that hydrates the result with the request's own mode. Without that, a url-mode accept — contentless by design — threw, because ElicitResult requires content whenever the action is accept. The result carries no discriminator of its own, so the mode has to come from the request it answers. `supportsElicitationUrl()` reports whether the client named the mode, reusing the sub-capability reader the sampling checks already use.
chr-hertel
force-pushed
the
feature/schema-2026-07-28-drift
branch
from
August 15, 2026 02:26
004c8ce to
fd4198f
Compare
Owner
Author
|
Closed in favor of modelcontextprotocol#421 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the schema gaps in the revisions this SDK already negotiates, and adds the surface
2026-07-28introduces on top.The original framing of this PR was wrong, and correcting it is most of the point. Sampling tool use, elicitation modes, the object-shaped client sub-capabilities and
Icon::themewere not introduced by2026-07-28— they all arrived in2025-11-25, which is this SDK's own default protocol version. They were simply missing, so a server using any of them was talking to a peer that answered with anInvalidArgumentException. What2026-07-28genuinely adds is narrower: three error codes and a loosening of two schemas.Verified item by item against
schema/2025-11-25/schema.tsandschema/draft/schema.ts(LATEST_PROTOCOL_VERSION = "2026-07-28").ToolUseContent,ToolResultContent,ToolChoice,tools/toolChoice2025-11-25SamplingMessage.content,_metaon sampling messages2025-11-25form/urlmodes2025-11-25sampling: {tools?, context?},elicitation: {form?, url?}2025-11-25Icon.theme2025-11-25Implementation.title2025-06-18(viaBaseMetadata)ResourceLinkin theContentBlockunion2025-06-18-32020/-32021/-320222026-07-28Tool.outputSchemaroot unconstrained2026-07-28structuredContentwidened to any JSON value2026-07-28Worth noting:
2026-07-28deprecates Sampling wholesale (SEP-2577). It stays specified for at least twelve more months, so implementing it remains correct — but the deprecation is why this PR does not build anything further on top of it.Sampling tool use (
2025-11-25)ToolUseContentandToolResultContentjoin the content union.CreateSamplingMessageRequesttakestoolsand atoolChoice(ToolChoice+ToolChoiceMode:auto,none,required).SamplingMessageandCreateSamplingMessageResultaccept a list of blocks as well as a single one — a list is what carries a tool-use turn — and both gain_meta, which clients are asked to preserve when replaying a turn into a later request.An unrecognized
toolChoicemode is rejected rather than read asauto. Coercing would turn "the model MUST use a tool" into "the model may decide", which is a different instruction, not a lenient reading of this one.SamplingRequestHandleralso enforces the capability it advertises. A server must not sendtoolsortoolChoiceto a client that never declaredsampling.tools, and the client must answer such a request with an error rather than sampling anyway — the callback would otherwise ignore tools it was never built to handle and return a plain completion, which the server reads as the model declining to call any of them rather than as the protocol violation it is. The declaredClientCapabilitiesare an optional third constructor argument, since the handler is built by the caller and handed to the builder and so has no route to the client's configuration; omitting it keeps the previous behaviour. The error isinvalid paramsrather than the purpose-builtMISSING_REQUIRED_CLIENT_CAPABILITY, which only exists from2026-07-28— the code a peer receives cannot yet depend on the negotiated revision.Elicitation modes (
2025-11-25)ElicitationModesplits the two things an elicitation can be:form— build a form from the requested schema and return the filled values.url— send the user out of band, and return only whether they accepted, declined, or cancelled.That is why
requestedSchemabecomes optional andurlappears beside it.Capabilities (
2025-11-25)ClientCapabilitieslearnssamplingTools,samplingContext,elicitationFormandelicitationUrl, parsed from the object-or-bool shapes the revision permits.An
elicitationcapability that names no mode declares form mode — the spec's backwards-compatible reading from beforeurlexisted, and an explicit example in the schema (elicitation-form-only-implicit.json). Naming a mode is an explicit statement, so{"url": {}}does not imply form.ResourceLinkcompletes theContentBlockunionContentBlockisTextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource, butResourceLinkwas absent from the SDK entirely. A legalresource_linkblock therefore threw fromCallToolResult,ToolResultContentandPromptMessagealike — every place the union appears. All three now hydrate it.Looser schemas (
2026-07-28)Tool::outputSchemamay describe any JSON value rather than only an object, so the roottype: "object"check is gone — unlikeinputSchema, whose root must stay an object because tool arguments are always a JSON object.CallToolResult::$structuredContentwidens from?arraytomixed.Error codes (
2026-07-28)-32020HEADER_MISMATCH-32021MISSING_REQUIRED_CLIENT_CAPABILITY-32022UNSUPPORTED_PROTOCOL_VERSIONProtocolVersionMiddlewareswitches to the last of them, so a rejected version carries the supported set as structureddatathe client can retry from rather than only as prose. Status stays400; the header-absent fallback and the default supported set are unchanged.Two bugs this surfaced
Icon::fromArray()read amimeTypeskey that does not exist, so everymimeTypearriving over the wire was silently dropped.IconTestcovered the constructor only, which is how it stayed hidden.Tools returning a PHP list emitted an invalid
structuredContent. Every revision the SDK negotiates requires an object there, and strict clients reject the whole message rather than ignoring the field.list_user_tasksin thecustom-dependenciesexample could not be called from the Inspector at all — onmainas much as here:The shape decision belongs in
ToolReference::extractStructuredContent(), where both the returned value and the tool's declaredoutputSchemaare known: a list is passed through only when the tool declares a schema asking for one, and otherwise reaches the client through the content blocks alone, which is where an unstructured list belongs.With that settled, serialization no longer has to guess.
nullis the only value that means absent, so an empty object — which every revision permits — stops being discarded along with the falsy values it had been lumped in with. This replaces the truthiness check an earlier revision of this PR argued for; the check was hiding the empty-list case of a real bug rather than guarding against anything.Behaviour changes worth flagging
Recorded as BC breaks in the new
0.8.0CHANGELOG section:structuredContentis withheld only whennull; a falsy value the caller supplied now reaches the wire.structuredContentunless the tool'soutputSchemaasks for one.ToolChoice::fromArray()throws on an unrecognizedmodeinstead of coercing it toauto.Testing
make csandmake phpstanclean. 952 unit tests, 1052 including the Inspector suite (7 skipped), all passing. Thelist_user_tasksfix is verified against the real Inspector, not only in unit tests.New coverage:
ResourceLinkTest,SamplingRequestHandlerTest,StructuredContentEmissionTest,ClientCapabilitiesTest,SamplingMessageTest,ToolUseContentTest,ToolResultContentTest,ElicitationModeTest,ErrorCodesTest,NonObjectOutputSchemaTest,SamplingToolUseTest, plusextractStructuredContentcases inRegistryTestandfromArraycases inIconTest.Known follow-up
Results still do not serialize per negotiated protocol version. Until they do, a caller targeting a pre-
2026-07-28client has to pass an object rather than a scalar forstructuredContent, andTool::outputSchemaaccepts a non-object root the negotiated revision may not permit.docs/transports.mdfrom modelcontextprotocol#403 does not yet mention the structuredUnsupportedProtocolVersionErrorpayload this PR introduces — worth a sentence once the shape settles.