Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.
* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`.
* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`.
* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set.

0.7.0
-----
Expand Down
15 changes: 14 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,19 @@ $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.

Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here.
Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning
saying so.

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
69 changes: 69 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,73 @@ $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`. The pin does not reach that check: the transport
> builds the middleware without access to the server configuration, so the header keeps being accepted for every
> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the
> same revision — 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 +729,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
11 changes: 11 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ 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_HEADER_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. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against
the set it was constructed with, not against the revision a given session negotiated, so a server that pins the
handshake has to pass that revision here as well.

### 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 @@ -19,6 +19,7 @@
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;
Expand Down Expand Up @@ -113,6 +114,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
36 changes: 35 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,20 @@ public function connect(TransportInterface $transport, Configuration $config): v
*/
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,
);
Expand All @@ -108,6 +121,26 @@ public function initialize(Configuration $config): Response|Error

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);
Expand All @@ -116,6 +149,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
Loading