[Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface - #421
Conversation
There was a problem hiding this comment.
Pull request overview
Adds the optional 2026-07-28 schema surface while preserving existing defaults.
Changes:
- Adds URL elicitation, sub-capabilities, icon themes, and implementation titles.
- Widens tool output schemas and structured content; hardens sampling types.
- Adds protocol error codes, structured version errors, and coverage.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
CHANGELOG.md |
Documents the schema additions. |
src/Client/Builder.php |
Supports client titles. |
src/Schema/ClientCapabilities.php |
Models elicitation sub-capabilities. |
src/Schema/Content/ToolUseContent.php |
Validates tool input and metadata. |
src/Schema/Enum/ElicitationMode.php |
Defines form and URL modes. |
src/Schema/Enum/IconTheme.php |
Defines light and dark themes. |
src/Schema/Icon.php |
Adds icon theme support. |
src/Schema/Implementation.php |
Adds implementation titles. |
src/Schema/JsonRpc/Error.php |
Adds protocol error codes and factories. |
src/Schema/Request/ElicitRequest.php |
Models form and URL elicitation. |
src/Schema/Result/CallToolResult.php |
Widens structured content values. |
src/Schema/Result/ElicitResult.php |
Makes result parsing mode-aware. |
src/Schema/Tool.php |
Allows non-object output schemas. |
src/Schema/ToolChoice.php |
Rejects explicit null modes. |
src/Server/Builder.php |
Supports server titles. |
src/Server/ClientGateway.php |
Adds URL elicitation APIs. |
src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php |
Returns structured version errors. |
tests/Unit/Client/BuilderTest.php |
Covers client title forwarding. |
tests/Unit/Schema/ClientCapabilitiesTest.php |
Covers elicitation capabilities. |
tests/Unit/Schema/Content/ToolUseContentTest.php |
Covers stricter tool inputs. |
tests/Unit/Schema/ElicitationModeTest.php |
Covers elicitation wire shapes. |
tests/Unit/Schema/IconTest.php |
Covers icon themes. |
tests/Unit/Schema/ImplementationTest.php |
Covers implementation titles. |
tests/Unit/Schema/JsonRpc/ErrorCodesTest.php |
Covers new error payloads. |
tests/Unit/Schema/NonObjectOutputSchemaTest.php |
Covers widened output values. |
tests/Unit/Schema/Result/ElicitResultTest.php |
Covers mode-aware results. |
tests/Unit/Schema/ToolChoiceTest.php |
Covers null mode rejection. |
tests/Unit/Server/BuilderTest.php |
Covers server title forwarding. |
tests/Unit/Server/ClientGatewayTest.php |
Covers URL elicitation and capabilities. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| public readonly array $content, | ||
| public readonly bool $isError = false, | ||
| public readonly ?array $structuredContent = null, | ||
| public readonly mixed $structuredContent = null, |
| $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; | ||
|
|
||
| if (ElicitAction::Accept === $action && null === $content) { | ||
| if (ElicitationMode::Form === $mode && ElicitAction::Accept === $action && null === $content) { |
| */ | ||
| public function elicitUrl(string $message, string $url, int $timeout = 120): ElicitResult | ||
| { | ||
| // URL mode only exists from 2025-11-25 on, and only for clients declaring it: |
There was a problem hiding this comment.
Declining this one — url-mode elicitation is a 2025-11-25 feature, so the comment is right where it stands.
schema/2025-11-25/schema.ts has ElicitRequestURLParams (L2185) and elicitation?: { form?: object; url?: object } (L341); 2026-07-28 carries both forward unchanged.
The other three are fixed in c1a0d3a. And this comment did surface a real mislabelling in the opposite direction: the PR framed url elicitation, Icon::theme and Implementation::title as 2026-07-28 additions when they are 2025-11-25 and 2025-06-18 gaps. Title, body, CHANGELOG and the first commit message now say so — only SEP-2106 and the three error codes are genuinely 2026-07-28.
| $outputSchema = null; | ||
| if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { | ||
| if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { | ||
| throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); | ||
| } | ||
| $outputSchema = $data['outputSchema']; |
…-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.
`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.
…to end Review follow-up on the SEP-2106 widening, which stopped at the type. `ToolReference::extractStructuredContent()` returned `?array`, so a tool declaring a scalar `outputSchema` had its result dropped and logged as unsendable. It returns `mixed` now and keeps a scalar — but only from 2026-07-28 on, and only when the tool declared an outputSchema: without one the value is already carried in `content`, and advertising it twice is not an improvement. `CallToolHandler` only warned about list-shaped `structuredContent` on a self-built `CallToolResult`, so a scalar reached revisions that require an object unremarked. The check is on the shape now, not on the list case alone. `ElicitResult` retained a `content` the spec says is absent from url-mode results, leaving a malformed response indistinguishable from a valid one. `Tool::jsonSerialize()` emitted the empty root schema as `[]`, which is not a schema at all. Sub-schemas already had this treatment.
fd4198f to
c1a0d3a
Compare
Ports the type definitions this SDK still misses outside sampling tool use — #409 and #420 cover that. Every addition is optional and defaults to current behaviour, so a connection negotiated on an older revision is unaffected.
From 2025-11-25
ElicitationModesplitsformfromurl, which sends the user out of band and returns only the accept/decline/cancel outcome — hence the optionalrequestedSchemaand the newurl.ClientGateway::elicitUrl()makes it sendable;ElicitResult::fromArray()takes the request's mode, requires content only in form mode, and rejects it in url mode.elicitation.form/elicitation.url, where a capability naming no mode declares form — the only shape that existed before url mode.Icon::theme.From 2025-06-18
Implementation::title(BaseMetadata), now settable throughClient\Builder::setClientInfo()andServer\Builder::setServerInfo().From 2026-07-28
Tool::outputSchemaandCallToolResult::structuredContentaccept any JSON value.ToolReference::extractStructuredContent()keeps a scalar when the tool declared anoutputSchemaand the negotiated revision allows it;CallToolHandlerwarns when a self-built result carries a value the revision does not permit.-32020header mismatch,-32021missing required client capability,-32022unsupported protocol version.ProtocolVersionMiddlewarereturns the last one with the supported set as structured data the client can retry from.Two fixes to the sampling types #409/#420 left untouched:
ToolChoice::fromArray()read its mode withisset(), so an explicit{"mode": null}becameautoinstead of being rejected; andToolUseContentaccepted a list asinput(serialized as a JSON array, which the protocol forbids) and silently dropped a malformed_meta.🤖 Generated with Claude Code