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

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

Ports the type definitions this SDK still misses outside of sampling tool use,
which #409 and #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.

* [Schema] Reject what the 2026-07-28 types cannot represent

`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.

* [Client][Server] Advertise the implementation title through both builders

`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.

* [Schema] Serialize every non-null structuredContent

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.

* [Schema][Server] Make url elicitation reachable

`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.

* [Schema] Add CHANGELOG entry for the 2026-07-28 surface

* [Capability][Schema][Server] Carry the widened structuredContent end 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.
This commit is contained in:
Christopher Hertel 2026-08-17 23:09:39 +02:00 • committed by GitHub
commit f84d8f5aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,6 +12,8 @@
namespace Mcp\Schema\JsonRpc;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ProtocolVersion;
/**
* A response to a request that indicates an error occurred.
@ -36,6 +38,24 @@ class Error implements MessageInterface
public const SERVER_ERROR = -32000;
public const RESOURCE_NOT_FOUND = -32002;
/**
* Values in the HTTP headers contradict the request body, or a required
* header is missing or malformed. Answered with `400 Bad Request`.
*/
public const HEADER_MISMATCH = -32020;
/**
* Handling the request needs a client capability the client never declared.
* Answered with `400 Bad Request`.
*/
public const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021;
/**
* The request's protocol version is unknown to this server, or is a version
* it has chosen not to implement. Answered with `400 Bad Request`.
*/
public const UNSUPPORTED_PROTOCOL_VERSION = -32022;
/**
* @param int $code the error type that occurred
* @param string $message a short description of the error
@ -111,6 +131,42 @@ class Error implements MessageInterface
return new self($id, self::RESOURCE_NOT_FOUND, $message);
}
final public static function forHeaderMismatch(string $message, string|int $id = ''): self
{
return new self($id, self::HEADER_MISMATCH, $message);
}
/**
* @param ClientCapabilities $requiredCapabilities the capabilities the server needs to process the request
*/
final public static function forMissingRequiredClientCapability(
string $message,
ClientCapabilities $requiredCapabilities,
string|int $id = '',
): self {
return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [
'requiredCapabilities' => $requiredCapabilities,
]);
}
/**
* The client is expected to pick a mutually supported version out of
* $supported and retry, so the list travels with the error rather than
* only in the message.
*
* @param list<ProtocolVersion> $supported versions this server does support
*/
final public static function forUnsupportedProtocolVersion(
string $requested,
array $supported,
string|int $id = '',
): self {
return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
'requested' => $requested,
'supported' => array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $supported)),
]);
}
public function getId(): string|int
{
return $this->id;