[Client][Schema][Server] Implement protocol version negotiation (#403)

* [Client][Docs][Schema][Server] Implement protocol version negotiation

The `initialize` handler never looked at the version the client asked for:
it always answered with the server's configured version (or the SDK default),
so a client offering a revision the server supports could still be told to
speak a different one. The spec requires echoing back a supported request and
only counter-offering when the requested version cannot be honoured.

Turns ProtocolVersion into the registry the other SDKs converged on
(typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py):
`latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered
comparison through `isAtLeast()`. Comparison goes through declaration order
rather than string collation, so an unrecognized peer string compares
conservatively instead of accidentally sorting above a known revision. The
one throw the enum can produce is the SDK's own LogicException, so a consumer
catching `Mcp\Exception\ExceptionInterface` does not miss it.

Adds 2026-07-28 as a known revision and splits the set into two eras:
handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern
(2026-07-28+, per-request `_meta`). The lists are kept apart so a modern
revision can never leak into an `initialize` exchange, which has no
`initialize` at all. Serving the modern era is follow-up work; this only
teaches the SDK that the revision exists and must not be mis-negotiated.
Deliberately absent is any "newest revision in any era" accessor, which
would hand back the one revision the handshake must never offer.

Server: negotiates and stores the result on the session. A version pinned in
Configuration still pins the handshake to exactly that revision.
Client: validates the counter-offer and fails the handshake instead of
continuing on a revision it cannot speak; records the negotiated version on
the client state, readable through the new Client::getProtocolVersion().

The HTTP middleware now defaults to the handshake versions rather than every
declared case, so it stops accepting a modern version the server cannot yet
serve. When the header is absent it falls back to
ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that
introduced both Streamable HTTP and the header itself.

The negotiation behaviour is one provideNegotiationTable() data provider
whose rows are the documented table -- supported revision echoed back,
unknown or malformed counter-offered, modern counter-offered -- so the docs
and the suite cannot drift apart. The supported-revision rows iterate
handshakeVersions() rather than a literal list, so declaring a new handshake
revision extends the table automatically, and the "never answer with a modern
revision" assertion runs on every row.

Documented in server-builder.md (the enum registry, how the server answers a
handshake, what pinning changes), transports.md (the header check) and
client.md (the client's side), all linking the spec's versioning section.

* [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated

* Log a warning instead of silently falling back when a modern revision is
  configured on the client or pinned on the server builder.
* Document that setProtocolVersion() does not restrict the MCP-Protocol-Version
  header check, which validates against a fixed set.
* Pin every revision's era in a test, so a new case declared past the modern
  boundary fails instead of dropping out of the handshake set.
* Add CHANGELOG entry.

* [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
This commit is contained in:
Christopher Hertel 2026-08-15 01:41:19 +02:00 • committed by GitHub
commit eb20453f49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 824 additions and 26 deletions

View file

@ -19,6 +19,7 @@ use Mcp\Exception\ConnectionException;
use Mcp\Exception\RequestException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
@ -113,6 +114,18 @@ class Client
return $this->protocol->getState()->getInstructions();
}
/**
* Protocol revision negotiated during the handshake.
*
* This is the version the server answered with, which is not necessarily the
* one configured on the builder: a server that cannot speak the requested
* revision counter-offers one it supports. Null until the handshake completed.
*/
public function getProtocolVersion(): ?ProtocolVersion
{
return $this->protocol->getState()->getProtocolVersion();
}
/**
* Send a ping request to the server.
*/

View file

@ -18,6 +18,7 @@ use Mcp\Client\State\ClientState;
use Mcp\Client\State\ClientStateInterface;
use Mcp\Client\Transport\TransportInterface;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
@ -98,8 +99,20 @@ class Protocol
*/
public function initialize(Configuration $config): Response|Error
{
$offered = $config->protocolVersion;
if ($offered->isModern()) {
// Only handshake era spec versions need the initialize call, so if we
// end up here, we fall back to the latest handshake version.
$offered = ProtocolVersion::latestHandshake();
$this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [
'configured' => $config->protocolVersion->value,
'offered' => $offered->value,
]);
}
$request = new InitializeRequest(
$config->protocolVersion->value,
$offered->value,
$config->capabilities,
$config->clientInfo,
);
@ -108,6 +121,26 @@ class Protocol
if ($response instanceof Response) {
$initResult = InitializeResult::fromArray($response->result);
// A counter-offer this SDK cannot speak leaves nothing to fall back to,
// so the handshake fails rather than continuing on a revision neither
// side agrees on.
$negotiated = $initResult->protocolVersion;
if (null === $negotiated || $negotiated->isModern()) {
// fromArray() above already rejected a missing or non-string revision.
$counterOffer = (string) $response->result['protocolVersion'];
return Error::forInvalidParams(\sprintf(
'Server responded with unsupported protocol version "%s". Supported versions: %s.',
$counterOffer,
implode(', ', array_map(
static fn (ProtocolVersion $v): string => $v->value,
ProtocolVersion::handshakeVersions(),
)),
), $response->id);
}
$this->state->setProtocolVersion($negotiated);
$this->state->setServerInfo($initResult->serverInfo);
$this->state->setInstructions($initResult->instructions);
$this->state->setInitialized(true);
@ -116,6 +149,7 @@ class Protocol
$this->logger->info('Initialization complete', [
'server' => $initResult->serverInfo->name,
'protocolVersion' => $negotiated->value,
]);
}

View file

@ -11,6 +11,7 @@
namespace Mcp\Client\State;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
@ -28,6 +29,7 @@ class ClientState implements ClientStateInterface
{
private int $requestIdCounter = 1;
private bool $initialized = false;
private ?ProtocolVersion $protocolVersion = null;
private ?Implementation $serverInfo = null;
private ?string $instructions = null;
@ -96,6 +98,16 @@ class ClientState implements ClientStateInterface
return $this->initialized;
}
public function setProtocolVersion(ProtocolVersion $protocolVersion): void
{
$this->protocolVersion = $protocolVersion;
}
public function getProtocolVersion(): ?ProtocolVersion
{
return $this->protocolVersion;
}
public function setServerInfo(Implementation $serverInfo): void
{
$this->serverInfo = $serverInfo;

View file

@ -11,6 +11,7 @@
namespace Mcp\Client\State;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
@ -76,6 +77,18 @@ interface ClientStateInterface
*/
public function isInitialized(): bool;
/**
* Store the protocol version negotiated during initialization.
*/
public function setProtocolVersion(ProtocolVersion $protocolVersion): void;
/**
* Get the protocol version negotiated during initialization.
*
* Null until the handshake has completed.
*/
public function getProtocolVersion(): ?ProtocolVersion;
/**
* Store the server info from initialization.
*/

View file

@ -11,15 +11,115 @@
namespace Mcp\Schema\Enum;
use Mcp\Exception\LogicException;
/**
* Available protocol versions for MCP.
* Registry of the MCP protocol revisions this SDK knows about.
*
* Cases are declared oldest to newest, and that declaration order — not the
* lexicographic order of the values — is what every comparison here relies on:
* revision identifiers are an enumerated set, not an ordered scalar, and a
* future one is not guaranteed to be date-shaped.
*
* The revisions split into two eras:
*
* - **handshake** (`2025-11-25` and earlier) negotiate a version through the
* `initialize` round-trip and keep session state for the connection;
* - **modern** ({@see self::FIRST_MODERN_VERSION} and later) drop `initialize`
* entirely — every request carries its own version in `_meta`, and servers
* advertise what they speak through `server/discover`.
*
* @see https://modelcontextprotocol.io/specification/draft/basic/versioning
*
* @author Illia Vasylevskyi<ineersa@gmail.com>
*/
enum ProtocolVersion: string
{
// Declaration order is also the era boundary: everything declared from
// FIRST_MODERN_VERSION onwards is modern. A new handshake-era revision has
// to be inserted above that case — appending it here would silently drop it
// out of the handshake negotiation.
case V2024_11_05 = '2024-11-05';
case V2025_03_26 = '2025-03-26';
case V2025_06_18 = '2025-06-18';
case V2025_11_25 = '2025-11-25';
case V2026_07_28 = '2026-07-28';
/**
* First revision of the modern era, in which the `initialize` handshake was
* replaced by per-request metadata.
*/
public const FIRST_MODERN_VERSION = self::V2026_07_28;
/**
* Version a server assumes when a client omits the `MCP-Protocol-Version`
* header on the Streamable HTTP transport.
*
* This is the revision that introduced both Streamable HTTP and the header
* itself, so a request without the header cannot be newer than this.
*/
public const DEFAULT_HEADER_VERSION = self::V2025_03_26;
/**
* Newest revision reachable through the `initialize` handshake.
*
* This is what a server counter-offers when it cannot honour the version a
* client asked for, and what a handshake-era client offers by default.
*/
public static function latestHandshake(): self
{
$versions = self::handshakeVersions();
return $versions[\count($versions) - 1];
}
/**
* Revisions reachable through the `initialize` handshake, oldest to newest.
*
* @return non-empty-list<self>
*/
public static function handshakeVersions(): array
{
return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern()));
}
/**
* Revisions using the per-request metadata envelope, oldest to newest.
*
* @return non-empty-list<self>
*/
public static function modernVersions(): array
{
return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern()));
}
/**
* Whether this revision belongs to the modern, per-request-metadata era.
*/
public function isModern(): bool
{
return $this->isAtLeast(self::FIRST_MODERN_VERSION);
}
/**
* Whether this revision is at least as new as $minimum.
*/
public function isAtLeast(self $minimum): bool
{
return $this->position() >= $minimum->position();
}
/**
* Index of this revision in the chronological declaration order.
*/
private function position(): int
{
foreach (self::cases() as $index => $case) {
if ($case === $this) {
return $index;
}
}
throw new LogicException(\sprintf('Protocol version "%s" is not a declared case.', $this->value));
}
}

View file

@ -717,6 +717,13 @@ final class Builder
$capabilities = $capabilities->withExtensions($this->extensions);
}
if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) {
$logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [
'configured' => $this->protocolVersion->value,
'negotiable' => array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()),
]);
}
$serverInfo = $this->serverInfo ?? new Implementation();
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);

View file

@ -11,6 +11,7 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
@ -47,6 +48,9 @@ final class InitializeHandler implements RequestHandlerInterface
$session->set('client_info', $request->clientInfo->jsonSerialize());
$session->set('client_capabilities', $request->capabilities->jsonSerialize());
$negotiated = $this->negotiate($request->protocolVersion);
$session->set('protocol_version', $negotiated->value);
return new Response(
$request->getId(),
new InitializeResult(
@ -54,8 +58,49 @@ final class InitializeHandler implements RequestHandlerInterface
$this->configuration->serverInfo ?? new Implementation(),
$this->configuration?->instructions,
null,
$this->configuration?->protocolVersion,
$negotiated,
),
);
}
/**
* Picks the protocol version to answer an `initialize` handshake with.
*
* If the client asked for a version this server supports, the spec requires
* responding with that exact version. Otherwise the server counter-offers
* the newest version it does support and leaves it to the client to decide
* whether it can continue on that revision or must disconnect.
*/
private function negotiate(string $requested): ProtocolVersion
{
$supported = $this->supportedVersions();
$version = ProtocolVersion::tryFrom($requested);
if (null !== $version && \in_array($version, $supported, true)) {
return $version;
}
return $supported[\count($supported) - 1];
}
/**
* Versions this server is willing to negotiate over `initialize`.
*
* A version configured on the server pins the handshake to exactly that
* revision. Modern revisions are never offered here: they have no
* `initialize` at all, so a client that reached this handler cannot speak
* one, and answering with it would leave the connection unusable.
*
* @return non-empty-list<ProtocolVersion>
*/
private function supportedVersions(): array
{
$configured = $this->configuration?->protocolVersion;
if (null !== $configured && !$configured->isModern()) {
return [$configured];
}
return ProtocolVersion::handshakeVersions();
}
}

View file

@ -35,6 +35,9 @@ use Psr\Http\Server\RequestHandlerInterface;
* the `initialize` round-trip does not carry the header, and legacy clients
* that omit it are tolerated.
*
* Validation is against a fixed set, not against the version a particular session
* negotiated: PSR-15 middleware runs before the transport resolves the session.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
@ -48,7 +51,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
private readonly array $supportedVersions;
/**
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}.
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
*/
@ -57,7 +60,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
$versions = $supportedVersions ?? ProtocolVersion::cases();
$versions = $supportedVersions ?? ProtocolVersion::handshakeVersions();
$this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions));
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
@ -70,10 +73,10 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
// Spec backwards-compat: when the header is absent, the server SHOULD assume
// protocol version 2025-03-26 — the release in which Streamable HTTP and the
// header itself were introduced. This is deliberately lower than the SDK's
// own default (V2025_06_18) so clients predating the header convention still
// get a deterministic protocol version applied. Servers that whitelist only
// newer versions in $supportedVersions will reject such requests with 400.
$version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue;
// own default so clients predating the header convention still get a
// deterministic protocol version applied. Servers that whitelist only newer
// versions in $supportedVersions will reject such requests with 400.
$version = '' === $headerValue ? ProtocolVersion::DEFAULT_HEADER_VERSION->value : $headerValue;
if (\in_array($version, $this->supportedVersions, true)) {
return $handler->handle($request);