Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2012fa6
[Client] Default to protocol version 2026-07-28
chr-hertel Oct 6, 2026
5e32ce7
[Server] Echo the request id when refusing a request without a session
chr-hertel Oct 6, 2026
b1f0a2f
[Server] Answer modern-era messages from a transport without headers
chr-hertel Oct 6, 2026
db47e4b
[Server] Serve both protocol eras over stdio
chr-hertel Oct 6, 2026
c3b2d82
[Client] Probe for the modern era and fall back to the handshake
chr-hertel Oct 6, 2026
8f5fe5f
[Client] Fail a request refused with an HTTP error status at once
chr-hertel Oct 6, 2026
954a065
[Server] Name the served revisions when refusing a bare initialize
chr-hertel Oct 6, 2026
f705284
[Server] Cover subscriptions/listen over stdio
chr-hertel Oct 6, 2026
d58b399
[Client] Fail at once when the stdio server process exits
chr-hertel Oct 6, 2026
103db3a
[Client] Adapt logging, ping and roots to a modern connection
chr-hertel Oct 6, 2026
a58831f
[Tests] Cover version negotiation end to end over stdio and HTTP
chr-hertel Oct 6, 2026
f898739
[Docs] Document version negotiation across both eras
chr-hertel Oct 6, 2026
8da51a6
[Client] Fail requests to an exited stdio server as answers
chr-hertel Oct 7, 2026
2101544
[Client] Require a connection to set the log level on a modern connec…
chr-hertel Oct 7, 2026
dfaf26f
[Client] Correlate an HTTP refusal only by the request's own id
chr-hertel Oct 7, 2026
fd0e112
[Server] Keep string and integer request ids apart on stdio
chr-hertel Oct 7, 2026
a72c988
[Docs] Split the fallback examples
chr-hertel Oct 7, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243).
* Fix `JwtTokenValidator` with several issuers always fetching the keys of the first one: keys now come from the issuer the token claims, which must be configured.
* Fix `RequestEvent`, `ResponseEvent` and `ErrorEvent` not being dispatched for `2026-07-28` requests.
* [BC Break] Bump the client's default protocol version to `2026-07-28`: `connect()` probes with `server/discover` and falls back to the `initialize` handshake on `2025-11-25` when the server does not speak the modern era. `Builder::setFallbackProtocolVersion()` picks the fallback revision, or `null` for a modern-only client; `Builder::setProtocolVersion(ProtocolVersion::V2025_11_25)` skips the probe.
* Serve both protocol eras over stdio: `StdioTransport` settles the era on the client's first request and serves `2026-07-28` requests, `subscriptions/listen` and `notifications/cancelled` on the one channel.
* On a `2026-07-28` connection, `Client::setLoggingLevel()` stamps the level on every following request, `Client::ping()` sends `server/discover` and `Client::sendRootsListChanged()` sends nothing.
* Answer a bare `initialize` on a `2026-07-28`-only endpoint with `-32022` naming the served revisions, and a request without a session on the handshake leg with its id.
* Fail a client request at once when the HTTP server refuses it with an error status or the stdio server process exits, instead of waiting out the timeout.
* [BC Break] Validate a tool result's `structuredContent` against the tool's `outputSchema`, which the specification requires the server to honour. A mismatch is answered with a `CallToolResult` carrying `isError: true` instead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares no `outputSchema`, when the result carries no `structuredContent`, and when the result is already an error.

0.8.0
Expand Down
34 changes: 23 additions & 11 deletions docs/client/connecting.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,30 +61,42 @@ $client = Client::builder()

### Protocol Version

Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the
latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly):
A client speaks both protocol eras by default. It prefers `2026-07-28`, probes for it with `server/discover` when
connecting, and falls back to the `initialize` handshake on `2025-11-25` when the server turns out not to speak it.
Use `$client->getProtocolVersion()` after connecting to read what the connection settled on.

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

// Fall back to an older handshake revision instead of 2025-11-25…
$client = Client::builder()
->setFallbackProtocolVersion(ProtocolVersion::V2025_06_18)
->build();

// …or not at all, refusing servers without the modern era.
$client = Client::builder()
->setFallbackProtocolVersion(null)
->build();
```

Passing a handshake revision to `setProtocolVersion()` skips the probe and opens with `initialize`, the way a client
from before the modern era would:

```php
$client = Client::builder()
->setProtocolVersion(ProtocolVersion::V2025_11_25)
->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
The handshake 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/latest/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.

Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no
`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing
else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath.
on.

See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the
exchange.
See [Protocol versions](../protocol-versions.md#how-the-client-settles-on-an-era) for how the probe is read, and
[the handshake era](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the exchange.

### Capabilities

Expand Down
59 changes: 48 additions & 11 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ map; the mechanics live with the task they belong to.
| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` |
| Dispatcher | `Protocol` | `StatelessProtocol` |
| HTTP entry | `StreamableHttpTransport` — the same one, for both |
| stdio entry | `StdioTransport` — the same one, settled by the client's first request |

`ProtocolVersion::isModern()` tells the two apart, and
`Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits.
Expand Down Expand Up @@ -105,28 +106,62 @@ either lifecycle. What changes:

## Speaking it from a client

One line selects the lifecycle; nothing else about the [client API](client/index.md) changes.
A client speaks both eras out of the box. It prefers `2026-07-28`, and finds out on
`connect()` whether the server does too; nothing about the [client API](client/index.md)
depends on the answer.

```php
$client = Client::builder()
->setClientInfo('my-client', '1.0.0')
->setProtocolVersion(ProtocolVersion::V2026_07_28)
->setCapabilities(new ClientCapabilities(elicitation: true))
->addRequestHandler($myElicitationHandler)
->build();

$client->connect(new HttpTransport('https://example.com/mcp'));

$client->getProtocolVersion(); // 2026-07-28, or 2025-11-25 against an older server
$client->callTool('greet', []);
```

What that changes underneath:
### How the client settles on an era

- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the
server's identity, and a server that does not answer it still yields a usable connection —
the method is optional. If discovery *does* report `supportedVersions` and the configured
revision is not among them, the client moves to a modern revision the server lists, or
refuses the connection outright rather than talking past it.
`connect()` probes with `server/discover`, stamped with the preferred revision, before
anything else — as the specification's backward-compatibility rules for
[stdio](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility)
and
[Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility)
describe.

| The probe gets | The client |
| --- | --- |
| a `DiscoverResult` listing a modern revision it speaks | stays modern, on that revision |
| `-32022` naming a modern revision it speaks | retries the probe with that revision |
| `-32022` naming only handshake revisions | falls back to `initialize` |
| `-32022` naming nothing it speaks | fails the connection |
| any other error, an HTTP refusal without one, or no answer in time | falls back to `initialize` |
| a `DiscoverResult` listing only handshake revisions | falls back to `initialize` |

The fallback is not keyed to one error code: servers from before the modern era refuse an
unexpected request however they like, or not at all. A refusal costs nothing — the client
falls back as soon as it arrives — but a server that stays silent costs the
[initialization timeout](client/connecting.md#basic-configuration). A server process that exits fails the
attempt outright: an outage is not an answer about the era.

The fallback offers `2025-11-25`; `setFallbackProtocolVersion()` picks another handshake
revision, and `setFallbackProtocolVersion(null)` makes the client modern-only, failing the
connection instead. A handshake revision passed to `setProtocolVersion()` skips the probe
and opens with `initialize`, as a client from before the modern era would.

Once modern, a handful of calls change shape under the same API: `setLoggingLevel()` rides on
every following request instead of sending the removed `logging/setLevel`, `ping()` becomes a
`server/discover`, and `sendRootsListChanged()` sends nothing, since roots are gone. Sampling
and roots are handshake-era features: a server asking for them on a modern connection fails the
call instead.

What being modern changes underneath:

- **No handshake.** `connect()` sends no `initialize`; the probe's `DiscoverResult` is what
fills in the server's identity and instructions.
- **An envelope on every request**, carrying the revision, the declared capabilities and the
client identity. The capabilities are what let a server decide, per request, whether it may
ask for input.
Expand All @@ -143,16 +178,18 @@ What that changes underneath:

Headers are an HTTP concern, so a transport opts into them by implementing
`HeaderAwareTransportInterface`; `HttpTransport` does, `StdioTransport` has nothing to carry
them on. Everything else — the envelope, the skipped handshake, the round-trip loop — applies
to both.
them on. Everything else — the probe, the envelope, the skipped handshake, the round-trip
loop — applies to both.

See
[`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php)
for a runnable version, described in [Examples](examples.md#modern-era-client).

## What was removed

Answered with `404` and `-32601` by a modern server:
Answered with `404` and `-32601` by a modern server — except a bare `initialize`, which is how
a client from before the modern era opens, and is refused with `-32022` naming the revisions
the server does speak:

- `initialize`, `notifications/initialized`
- `ping`
Expand Down
25 changes: 25 additions & 0 deletions docs/run/protocol-eras.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@ Both legs come from **one** builder configuration — one registry, one set of h
instances, one session manager. A tool registered once is reachable from both, and a change
made through one is visible to the other.

## Over stdio

`StdioTransport` serves both eras too, but stdio carries one client per process, so the era is
settled once rather than per request: the client's **first request** decides it, by the same
body-primary rule as above.

| Opening request | The connection |
| --- | --- |
| carries a modern revision in `params._meta` | is served by the modern dispatcher from then on |
| anything else — `initialize` above all | runs the handshake, as before `2026-07-28` |

A request from the other era after that is refused rather than served: `initialize` on a modern
connection gets `-32022` naming the modern revisions, an enveloped request on a handshake one
gets `-32600`. That is what a client that probed, gave up waiting and fell back to the handshake
needs to learn that the server settled on the modern era after all.

On a modern connection everything shares the one channel. A request's progress and log
messages are written as its handler emits them, ahead of its result; a `subscriptions/listen`
stays open alongside other requests, each of its messages tagged with the subscription id; and
`notifications/cancelled` is how a client stops one, since there is no per-request stream to
close. stdio has no headers, so none of the `Mcp-*` header rules apply.

A server built `withoutModernEra()` refuses a modern opening with `-32022` naming the handshake
revisions, and still accepts the handshake that follows.

## Middleware

The [default middleware stack](http.md#default-middleware) runs at the edge, before the
Expand Down
8 changes: 4 additions & 4 deletions examples/server/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
/**
* The transport every example runs on.
*
* Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport`
* classifies each request and routes it to the lifecycle it belongs to, so every
* example here answers an `initialize` handshake and a 2026-07-28 envelope alike.
* Over stdio there is no such choice to make — that binding carries the handshake era.
* Either way it serves both protocol eras: over HTTP, `StreamableHttpTransport`
* classifies each request and routes it to the lifecycle it belongs to; over stdio,
* `StdioTransport` settles the era on the client's first request. So every example
* here answers an `initialize` handshake and a 2026-07-28 envelope alike.
*
* @return TransportInterface<int>|TransportInterface<ResponseInterface>
*/
Expand Down
30 changes: 28 additions & 2 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Mcp\Schema\PromptReference;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\CompletionCompleteRequest;
use Mcp\Schema\Request\DiscoverRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\ListPromptsRequest;
use Mcp\Schema\Request\ListResourcesRequest;
Expand Down Expand Up @@ -157,11 +158,14 @@ public function getProtocolVersion(): ?ProtocolVersion
}

/**
* Send a ping request to the server.
* Check that the server is reachable and answering.
*
* A `ping` on the handshake era. The modern era removed it, so there the
* check is a `server/discover`, which every modern server answers.
*/
public function ping(): void
{
$request = new PingRequest();
$request = $this->protocol->isModern() ? new DiscoverRequest() : new PingRequest();

$this->sendRequest($request);
}
Expand Down Expand Up @@ -299,9 +303,23 @@ public function complete(PromptReference|ResourceReference $ref, array $argument

/**
* Set the minimum logging level for server log messages.
*
* On the handshake era this is a `logging/setLevel` request. The modern
* era removed it: there the level rides on every request that follows,
* and until one is set the server sends no log messages at all.
*/
public function setLoggingLevel(LoggingLevel $level): void
{
if (!$this->isConnected()) {
throw new ConnectionException('Client is not connected. Call connect() first.');
}

if ($this->protocol->isModern()) {
$this->protocol->setLogLevel($level);
Comment thread
Copilot marked this conversation as resolved.

return;
}

$request = new SetLogLevelRequest($level);

$this->sendRequest($request);
Expand All @@ -325,6 +343,14 @@ public function sendRootsListChanged(): void
throw new ConnectionException('Client is not connected. Call connect() first.');
}

// The modern era removed roots, so a server on it has nothing to
// refresh — and nothing to tell.
if ($this->protocol->isModern()) {
$this->logger->debug('Not sending "notifications/roots/list_changed": the connection is on the modern era, which removed roots.');

return;
}

$this->protocol->sendNotification(new RootsListChangedNotification());
}

Expand Down
25 changes: 23 additions & 2 deletions src/Client/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ final class Builder
private ?string $description = null;
private ?string $title = null;
private ?ProtocolVersion $protocolVersion = null;
private ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25;
private ?ClientCapabilities $capabilities = null;

/** @var array<string, array<string, mixed>> */
Expand Down Expand Up @@ -66,7 +67,12 @@ public function setClientInfo(string $name, string $version, ?string $descriptio
}

/**
* Set the protocol version to use.
* Set the protocol version the client prefers.
*
* Defaults to 2026-07-28. A modern revision is probed for with
* `server/discover` and falls back to the `initialize` handshake when the
* server turns out not to speak it, see {@see self::setFallbackProtocolVersion()};
* a handshake revision skips the probe and opens with the handshake.
*/
public function setProtocolVersion(ProtocolVersion $protocolVersion): self
{
Expand All @@ -75,6 +81,20 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self
return $this;
}

/**
* Set the handshake revision a modern client falls back to when the server
* does not speak the modern era. Defaults to 2025-11-25.
*
* Null makes the client modern-only: a server without the modern era then
* fails the connection instead.
*/
public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self
{
$this->fallbackProtocolVersion = $protocolVersion;

return $this;
}

/**
* Set client capabilities.
*/
Expand Down Expand Up @@ -198,10 +218,11 @@ public function build(): Client
$config = new Configuration(
clientInfo: $clientInfo,
capabilities: $capabilities,
protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25,
protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2026_07_28,
initTimeout: $this->initTimeout,
requestTimeout: $this->requestTimeout,
maxRetries: $this->maxRetries,
fallbackProtocolVersion: $this->fallbackProtocolVersion,
);

$protocol = new Protocol(
Expand Down
15 changes: 14 additions & 1 deletion src/Client/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@
*/
class Configuration
{
/**
* @param ProtocolVersion $protocolVersion the revision the client prefers. A modern one is
* probed for with `server/discover` before anything else
* @param ProtocolVersion|null $fallbackProtocolVersion the handshake revision offered through `initialize` when
* a probe shows the server does not speak the modern era;
* null makes a modern client modern-only. Unused when
* $protocolVersion is a handshake revision already
*/
public function __construct(
public readonly Implementation $clientInfo,
public readonly ClientCapabilities $capabilities,
public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_11_25,
public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2026_07_28,
public readonly int $initTimeout = 30,
public readonly int $requestTimeout = 120,
public readonly int $maxRetries = 3,
public readonly ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25,
) {
if (null !== $fallbackProtocolVersion && $fallbackProtocolVersion->isModern()) {
throw new InvalidArgumentException(\sprintf('The fallback protocol version must be one reached through the "initialize" handshake, got "%s".', $fallbackProtocolVersion->value));
}

if ($initTimeout < 1) {
throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout));
}
Expand Down
Loading
Loading