Skip to content

[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
mainfrom
feature/schema-2026-07-28-drift
Closed

[Client][Schema][Server] Close the 2025-11-25 schema gaps, add the 2026-07-28 surface#1
chr-hertel wants to merge 10 commits into
mainfrom
feature/schema-2026-07-28-drift

Conversation

@chr-hertel

@chr-hertel chr-hertel commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes the schema gaps in the revisions this SDK already negotiates, and adds the surface 2026-07-28 introduces 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::theme were not introduced by 2026-07-28 — they all arrived in 2025-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 an InvalidArgumentException. What 2026-07-28 genuinely adds is narrower: three error codes and a loosening of two schemas.

Verified item by item against schema/2025-11-25/schema.ts and schema/draft/schema.ts (LATEST_PROTOCOL_VERSION = "2026-07-28").

Addition Introduced in
ToolUseContent, ToolResultContent, ToolChoice, tools/toolChoice 2025-11-25
List-valued SamplingMessage.content, _meta on sampling messages 2025-11-25
Elicitation form / url modes 2025-11-25
sampling: {tools?, context?}, elicitation: {form?, url?} 2025-11-25
Icon.theme 2025-11-25
Implementation.title 2025-06-18 (via BaseMetadata)
ResourceLink in the ContentBlock union 2025-06-18
Error codes -32020 / -32021 / -32022 2026-07-28
Tool.outputSchema root unconstrained 2026-07-28
structuredContent widened to any JSON value 2026-07-28

Worth noting: 2026-07-28 deprecates 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)

ToolUseContent and ToolResultContent join the content union. CreateSamplingMessageRequest takes tools and a toolChoice (ToolChoice + ToolChoiceMode: auto, none, required). SamplingMessage and CreateSamplingMessageResult accept 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 toolChoice mode is rejected rather than read as auto. 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.

SamplingRequestHandler also enforces the capability it advertises. A server must not send tools or toolChoice to a client that never declared sampling.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 declared ClientCapabilities are 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 is invalid params rather than the purpose-built MISSING_REQUIRED_CLIENT_CAPABILITY, which only exists from 2026-07-28 — the code a peer receives cannot yet depend on the negotiated revision.

Elicitation modes (2025-11-25)

ElicitationMode splits 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 requestedSchema becomes optional and url appears beside it.

Capabilities (2025-11-25)

ClientCapabilities learns samplingTools, samplingContext, elicitationForm and elicitationUrl, parsed from the object-or-bool shapes the revision permits.

An elicitation capability that names no mode declares form mode — the spec's backwards-compatible reading from before url existed, 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.

ResourceLink completes the ContentBlock union

ContentBlock is TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource, but ResourceLink was absent from the SDK entirely. A legal resource_link block therefore threw from CallToolResult, ToolResultContent and PromptMessage alike — every place the union appears. All three now hydrate it.

Looser schemas (2026-07-28)

Tool::outputSchema may describe any JSON value rather than only an object, so the root type: "object" check is gone — unlike inputSchema, whose root must stay an object because tool arguments are always a JSON object. CallToolResult::$structuredContent widens from ?array to mixed.

Error codes (2026-07-28)

Code Constant
-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. Status stays 400; the header-absent fallback and the default supported set are unchanged.

Two bugs this surfaced

Icon::fromArray() read a mimeTypes key that does not exist, so every mimeType arriving over the wire was silently dropped. IconTest covered 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_tasks in the custom-dependencies example could not be called from the Inspector at all — on main as much as here:

Invalid input: expected record, received array  →  path: ["structuredContent"]

The shape decision belongs in ToolReference::extractStructuredContent(), where both the returned value and the tool's declared outputSchema are 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. null is 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.0 CHANGELOG section:

  • structuredContent is withheld only when null; a falsy value the caller supplied now reaches the wire.
  • A list no longer becomes structuredContent unless the tool's outputSchema asks for one.
  • ToolChoice::fromArray() throws on an unrecognized mode instead of coercing it to auto.

Testing

make cs and make phpstan clean. 952 unit tests, 1052 including the Inspector suite (7 skipped), all passing. The list_user_tasks fix is verified against the real Inspector, not only in unit tests.

New coverage: ResourceLinkTest, SamplingRequestHandlerTest, StructuredContentEmissionTest, ClientCapabilitiesTest, SamplingMessageTest, ToolUseContentTest, ToolResultContentTest, ElicitationModeTest, ErrorCodesTest, NonObjectOutputSchemaTest, SamplingToolUseTest, plus extractStructuredContent cases in RegistryTest and fromArray cases in IconTest.

Known follow-up

Results still do not serialize per negotiated protocol version. Until they do, a caller targeting a pre-2026-07-28 client has to pass an object rather than a scalar for structuredContent, and Tool::outputSchema accepts a non-object root the negotiated revision may not permit. docs/transports.md from modelcontextprotocol#403 does not yet mention the structured UnsupportedProtocolVersionError payload this PR introduces — worth a sentence once the shape settles.

@chr-hertel
chr-hertel force-pushed the feature/protocol-version-negotiation branch from 1a6023e to 6042d3d Compare July 27, 2026 15:18
@chr-hertel
chr-hertel force-pushed the feature/schema-2026-07-28-drift branch 2 times, most recently from b4d89d9 to 02fefc3 Compare July 27, 2026 15:43
@chr-hertel chr-hertel changed the title [Schema][Server] Add the 2026-07-28 schema surface [Client][Schema][Server] Close the 2025-11-25 schema gaps, add the 2026-07-28 surface Jul 27, 2026
@chr-hertel
chr-hertel marked this pull request as ready for review July 27, 2026 15:49
@chr-hertel
chr-hertel force-pushed the feature/protocol-version-negotiation branch from 6042d3d to 8e9d08c Compare August 14, 2026 21:52
@chr-hertel
chr-hertel force-pushed the feature/schema-2026-07-28-drift branch from 02fefc3 to 004c8ce Compare August 15, 2026 00:30
@chr-hertel
chr-hertel changed the base branch from feature/protocol-version-negotiation to main August 15, 2026 00:30
@chr-hertel
chr-hertel requested a balanced review from Copilot August 15, 2026 00:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, and const. 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 thread src/Schema/Content/SamplingMessage.php Outdated
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 thread src/Schema/ToolChoice.php Outdated
Comment on lines +36 to +38
if (!isset($data['mode'])) {
return new self();
}
Comment thread src/Schema/Request/ElicitRequest.php Outdated
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 thread src/Schema/Result/CallToolResult.php Outdated
Comment on lines 157 to 158
if ($this->structuredContent) {
$result['structuredContent'] = $this->structuredContent;
chr-hertel and others added 10 commits August 15, 2026 02:43
…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
chr-hertel force-pushed the feature/schema-2026-07-28-drift branch from 004c8ce to fd4198f Compare August 15, 2026 02:26
@chr-hertel

Copy link
Copy Markdown
Owner Author

Closed in favor of modelcontextprotocol#421

@chr-hertel chr-hertel closed this Aug 15, 2026
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