Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6d67fd8
[Client] Probe for the modern era and fall back to the handshake
chr-hertel Oct 6, 2026
238f856
[Client] Fail a request refused with an HTTP error status at once
chr-hertel Oct 6, 2026
9df57e2
[Client] Fail at once when the stdio server process exits
chr-hertel Oct 6, 2026
a25b03b
[Client] Adapt logging, ping and roots to a modern connection
chr-hertel Oct 6, 2026
1ab9ead
[Client] Fail requests to an exited stdio server as answers
chr-hertel Oct 7, 2026
39a1d2a
[Client] Require a connection to set the log level on a modern connec…
chr-hertel Oct 7, 2026
5bf6611
[Client] Correlate an HTTP refusal only by the request's own id
chr-hertel Oct 7, 2026
0efa58c
[Tests] Cover version negotiation end to end over stdio and HTTP
chr-hertel Oct 7, 2026
98ba324
[Docs] Document client version negotiation
chr-hertel Oct 7, 2026
8b13237
[Client] Require a probe answer to name its revisions before settling…
chr-hertel Oct 7, 2026
dfbf1f7
[Client] Fail an HTTP refusal at once when its body is no well-formed…
chr-hertel Oct 7, 2026
e512dec
[Client] Deliver progress in order with the notifications around it
chr-hertel Oct 7, 2026
aba959c
[Client] Keep a final answer read together with the end of the stdio …
chr-hertel Oct 9, 2026
a78826f
[Client] Check an HTTP refusal body with the message parsers before t…
chr-hertel Oct 9, 2026
fafc3e5
[Client] Never file an HTTP refusal of a client response under the se…
chr-hertel Oct 9, 2026
8475897
[Client] Fail the probe on a lost stdio connection instead of falling…
chr-hertel Oct 9, 2026
9d3e463
[Client] Read the revisions a refusal names in one place
chr-hertel Oct 9, 2026
0dce8a9
[Client] Forget the modern log level on reconnect
chr-hertel Oct 9, 2026
75fd305
[Client] Pick the newest advertised revision without relying on decla…
chr-hertel Oct 9, 2026
2850a99
[Client] Trim comments
chr-hertel Oct 9, 2026
7cc324e
[Client] Re-probe once by recursion instead of a two-pass loop
chr-hertel Oct 9, 2026
f300d79
[Client] Deliver progress as it is parsed, keeping its order within a…
chr-hertel Oct 9, 2026
0a6f7b5
[Tests] Pin the server's refusal code an id-less HTTP refusal carries
chr-hertel Oct 10, 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
* 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.
* [BC Break] `StatelessAwareTransportInterface` declares `setHandshakeVersions()`, so a server without the modern era names only the revisions it negotiates when refusing a `2026-07-28` request, e.g. the one set with `Builder::setProtocolVersion()`.
* 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.
* Speak both protocol eras from a client configured with `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.
* 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.
* 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.

0.8.0
-----
Expand Down
35 changes: 24 additions & 11 deletions docs/client/connecting.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ $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):
Specify the MCP protocol version to speak (defaults to `V2025_11_25`). A handshake revision opens with `initialize`:

```php
use Mcp\Schema\Enum\ProtocolVersion;
Expand All @@ -72,19 +71,33 @@ $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
A modern revision makes the client speak both protocol eras: it probes for `2026-07-28` 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
// Fall back to an older handshake revision instead of 2025-11-25…
$client = Client::builder()
->setProtocolVersion(ProtocolVersion::V2026_07_28)
->setFallbackProtocolVersion(ProtocolVersion::V2025_06_18)
->build();

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

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
53 changes: 44 additions & 9 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ either lifecycle. What changes:

## Speaking it from a client

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

```php
$client = Client::builder()
Expand All @@ -116,16 +118,49 @@ $client = Client::builder()

$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 @@ -142,8 +177,8 @@ 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)
Expand Down
31 changes: 27 additions & 4 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 @@ -173,11 +174,11 @@ public function getProtocolVersion(): ?ProtocolVersion
}

/**
* Send a ping request to the server.
* Check that the server is reachable: `ping`, or `server/discover` on the modern era, which removed it.
*/
public function ping(): void
{
$request = new PingRequest();
$request = $this->protocol->isModern() ? new DiscoverRequest() : new PingRequest();

$this->sendRequest($request);
}
Expand Down Expand Up @@ -336,10 +337,20 @@ public function complete(PromptReference|ResourceReference $ref, array $argument
}

/**
* Set the minimum logging level for server log messages.
* Set the minimum logging level for server log messages; on the modern era it rides on every following request.
*/
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);

return;
}

$request = new SetLogLevelRequest($level);

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

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 All @@ -384,7 +401,13 @@ private function sendRequest(Request $request, ?callable $onProgress = null, ?Ca

$withProgress = null !== $onProgress;
$fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress, $cancellation, $timeoutSeconds));
$response = $transport->runRequest($fiber, $onProgress);
$this->protocol->setProgressCallback($onProgress);

try {
$response = $transport->runRequest($fiber);
} finally {
$this->protocol->setProgressCallback(null);
}

if ($response instanceof Error) {
throw RequestException::fromError($response);
Expand Down
14 changes: 13 additions & 1 deletion 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,7 @@ public function setClientInfo(string $name, string $version, ?string $descriptio
}

/**
* Set the protocol version to use.
* Set the protocol version the client prefers, defaults to 2025-11-25; a modern one is probed for first.
*/
public function setProtocolVersion(ProtocolVersion $protocolVersion): self
{
Expand All @@ -75,6 +76,16 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self
return $this;
}

/**
* Set the handshake revision a modern client falls back to, defaults to 2025-11-25; null makes it modern-only.
*/
public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self
{
$this->fallbackProtocolVersion = $protocolVersion;

return $this;
}

/**
* Set client capabilities.
*/
Expand Down Expand Up @@ -202,6 +213,7 @@ public function build(): Client
initTimeout: $this->initTimeout,
requestTimeout: $this->requestTimeout,
maxRetries: $this->maxRetries,
fallbackProtocolVersion: $this->fallbackProtocolVersion,
);

$protocol = new Protocol(
Expand Down
8 changes: 8 additions & 0 deletions src/Client/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@
*/
class Configuration
{
/**
* @param ProtocolVersion|null $fallbackProtocolVersion handshake revision a modern client falls back to; null makes it modern-only
*/
public function __construct(
public readonly Implementation $clientInfo,
public readonly ClientCapabilities $capabilities,
public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_11_25,
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,25 @@

namespace Mcp\Client\Handler\Notification;

use Mcp\Client\State\ClientStateInterface;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\Notification\ProgressNotification;

/**
* Internal handler for progress notifications.
*
* Writes progress data to state for transport to consume and execute callbacks.
* Hands progress on as soon as it is parsed, so it keeps its order among other notifications.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*
* @internal
*/
class ProgressNotificationHandler implements NotificationHandlerInterface
{
/**
* @param \Closure(float, ?float, ?string): void $deliver
*/
public function __construct(
private readonly ClientStateInterface $state,
private readonly \Closure $deliver,
) {
}

Expand All @@ -42,11 +44,6 @@ public function handle(Notification $notification): void
return;
}

$this->state->storeProgress(
(string) $notification->progressToken,
$notification->progress,
$notification->total,
$notification->message,
);
($this->deliver)($notification->progress, $notification->total, $notification->message);
}
}
Loading
Loading