[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:
parent
fc456125e2
commit
eb20453f49
8 changed files with 824 additions and 26 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue