Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ $client = Client::builder()

### Protocol Version

Specify the MCP protocol version (defaults to latest):
Specify the MCP protocol version to offer during the handshake (defaults to the latest):

```php
use Mcp\Schema\Enum\ProtocolVersion;
Expand All @@ -87,6 +87,15 @@ $client = Client::builder()
->build();
```

This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as
described in the specification's
[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation)
section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK
cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed
on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated.

See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange.

### Capabilities

Declare client capabilities to enable server features:
Expand Down
67 changes: 67 additions & 0 deletions docs/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ various aspects of the server behavior.

- [Basic Usage](#basic-usage)
- [Server Configuration](#server-configuration)
- [Protocol Version Negotiation](#protocol-version-negotiation)
- [Discovery Configuration](#discovery-configuration)
- [Session Management](#session-management)
- [Manual Capability Registration](#manual-capability-registration)
Expand Down Expand Up @@ -90,6 +91,71 @@ $server = Server::builder()
->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.');
```

### Protocol Version

By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do
not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that
negotiation resolves, and for what `setProtocolVersion()` changes:

```php
use Mcp\Schema\Enum\ProtocolVersion;

$server = Server::builder()
->setProtocolVersion(ProtocolVersion::V2025_06_18);
```

## Protocol Version Negotiation

MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in
its `initialize` request, and the server answers with the revision the connection will actually use. Both sides
disconnect if they cannot agree. This follows the
[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation)
section of the specification.

The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest:

```php
use Mcp\Schema\Enum\ProtocolVersion;

ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize`
ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first
ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true
```

Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today,
but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically.

### How the server answers

| Client requests | Server responds with |
| --- | --- |
| A revision the server supports | That same revision |
| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer |
| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer |

A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the
connection. The negotiated revision is stored on the session under `protocol_version`.

The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through
this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions
would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to
mis-negotiate it.

This table is mirrored by the `provideNegotiationTable()` data provider in
`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so
a newly declared revision is covered automatically.

### Pinning a revision

`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The
pin wins over the client's request, so a client asking for anything else receives the pinned revision as a
counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions.

> [!NOTE]
> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header,
> which is validated separately by `ProtocolVersionMiddleware`. See
> [Protocol Version Validation](transports.md#protocol-version-validation).

## Discovery Configuration

**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes.
Expand Down Expand Up @@ -661,6 +727,7 @@ $server = Server::builder()
| `setServerInfo()` | name, version, description? | Set server identity |
| `setPaginationLimit()` | limit | Set max items per page |
| `setInstructions()` | instructions | Set usage instructions |
| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision |
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
| `setLogger()` | logger | Set PSR-3 logger |
Expand Down
9 changes: 9 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
```

The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over
`initialize`, rather than every revision the enum declares. A request without the header is treated as
`ProtocolVersion::DEFAULT_NEGOTIATED_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and the
header itself, so a header-less request cannot be newer than that.

This header check is separate from, and happens after, the handshake itself. See
[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the
first place.

### Request Body Size Limit

`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or
Expand Down
13 changes: 13 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Mcp\Exception\ConnectionException;
use Mcp\Exception\RequestException;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
Expand Down Expand Up @@ -111,6 +112,18 @@ public function getInstructions(): ?string
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.
*/
Expand Down
31 changes: 30 additions & 1 deletion src/Client/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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;
Expand Down Expand Up @@ -98,8 +99,15 @@ public function connect(TransportInterface $transport, Configuration $config): v
*/
public function initialize(Configuration $config): Response|Error
{
// `initialize` only exists in the handshake era, so a client configured with
// a modern revision still opens with the newest handshake one. Reaching a
// modern revision is a separate negotiation, not this handshake.
$offered = $config->protocolVersion->isModern()
? ProtocolVersion::latestHandshake()
: $config->protocolVersion;

$request = new InitializeRequest(
$config->protocolVersion->value,
$offered->value,
$config->capabilities,
$config->clientInfo,
);
Expand All @@ -108,6 +116,26 @@ public function initialize(Configuration $config): Response|Error

if ($response instanceof Response) {
$initResult = InitializeResult::fromArray($response->result);

// The server either echoes the requested version or counter-offers one
// of its own. A counter-offer this SDK cannot speak leaves nothing to
// fall back to, so the handshake fails instead of continuing on a
// revision neither side agrees on.
$negotiated = $initResult->protocolVersion;
if (null === $negotiated || $negotiated->isModern()) {
$counterOffer = $response->result['protocolVersion'] ?? null;

return Error::forInvalidParams(\sprintf(
'Server responded with unsupported protocol version "%s". Supported versions: %s.',
\is_string($counterOffer) ? $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);
Expand All @@ -116,6 +144,7 @@ public function initialize(Configuration $config): Response|Error

$this->logger->info('Initialization complete', [
'server' => $initResult->serverInfo->name,
'protocolVersion' => $negotiated->value,
]);
}

Expand Down
12 changes: 12 additions & 0 deletions src/Client/State/ClientState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -96,6 +98,16 @@ public function isInitialized(): bool
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;
Expand Down
13 changes: 13 additions & 0 deletions src/Client/State/ClientStateInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +77,18 @@ public function setInitialized(bool $initialized): void;
*/
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.
*/
Expand Down
103 changes: 102 additions & 1 deletion src/Schema/Enum/ProtocolVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,30 @@

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 happen to be ISO dates today, but they are an enumerated
* set rather than an ordered scalar: future identifiers are not guaranteed to be
* date-shaped, and an unrecognized peer string must compare conservatively
* instead of accidentally sorting above a known revision.
*
* 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`.
*
* The two lists are deliberately kept apart so that adding a revision to one era
* can never leak a version string into the other era's negotiation.
*
* @see https://modelcontextprotocol.io/specification/draft/basic/versioning
*
* @author Illia Vasylevskyi<ineersa@gmail.com>
*/
Expand All @@ -22,4 +44,83 @@ enum ProtocolVersion: string
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_NEGOTIATED_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));
}
}
Loading