Skip to content

[Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface - #421

Open
chr-hertel wants to merge 7 commits into
modelcontextprotocol:mainfrom
chr-hertel:feature/schema-2026-07-28-drift
Open

[Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface#421
chr-hertel wants to merge 7 commits into
modelcontextprotocol:mainfrom
chr-hertel:feature/schema-2026-07-28-drift

Conversation

@chr-hertel

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

Copy link
Copy Markdown
Member

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

  • Url elicitation. ElicitationMode splits form from url, which sends the user out of band and returns only the accept/decline/cancel outcome — hence the optional requestedSchema and the new url. 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 sub-capabilities. 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 through Client\Builder::setClientInfo() and Server\Builder::setServerInfo().

From 2026-07-28

  • SEP-2106. Tool::outputSchema and CallToolResult::structuredContent accept any JSON value. ToolReference::extractStructuredContent() keeps a scalar when the tool declared an outputSchema and the negotiated revision allows it; CallToolHandler warns when a self-built result carries a value the revision does not permit.
  • Error codes -32020 header mismatch, -32021 missing required client capability, -32022 unsupported protocol version. ProtocolVersionMiddleware returns 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 with isset(), so an explicit {"mode": null} became auto instead of being rejected; and ToolUseContent accepted a list as input (serialized as a JSON array, which the protocol forbids) and silently dropped a malformed _meta.

🤖 Generated with Claude Code

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

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,
Comment thread src/Schema/Result/ElicitResult.php Outdated
Comment on lines +57 to +59
$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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread src/Schema/Tool.php
Comment on lines 148 to 150
$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.
@chr-hertel
chr-hertel force-pushed the feature/schema-2026-07-28-drift branch from fd4198f to c1a0d3a Compare August 15, 2026 03:02
@chr-hertel chr-hertel changed the title [Schema][Server] Add the 2026-07-28 schema surface outside sampling [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface 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