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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [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.
* [BC Break] Bump the client's default protocol version to `2026-07-28`, so a client speaks both protocol eras unless told otherwise. Against a server that speaks both, a default client now settles on `2026-07-28`, where a server can no longer sample or list roots through `ClientGateway`; return those asks as an `InputRequiredResult` instead, or call `Builder::setProtocolVersion(ProtocolVersion::V2025_11_25)` on the 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.
* [BC Break] Bump `MessageInterface::PROTOCOL_VERSION` to `2026-07-28`. Use `ProtocolVersion::latestHandshake()` where a handshake revision is needed, e.g. in an `initialize` answer.

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

### Protocol Version

Specify the MCP protocol version to speak (defaults to `V2025_11_25`). A handshake revision opens with `initialize`:

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

$client = Client::builder()
->setProtocolVersion(ProtocolVersion::V2025_11_25)
->build();
```

A modern revision makes the client speak both protocol eras: it probes for `2026-07-28` with `server/discover` when
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()
->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();
```

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();
```

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

## Speaking it from a client

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.
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();
Expand Down Expand Up @@ -153,9 +152,10 @@ 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.
`server/discover`, and `sendRootsListChanged()` sends nothing, since the server asks for roots
with each call that needs them. A server can still ask for sampling and roots by
[returning the ask](handlers/input-required.md); calling out for them through `ClientGateway`
is handshake-era only and fails the call on a modern connection.

What being modern changes underneath:

Expand Down
4 changes: 4 additions & 0 deletions examples/client/http_client_communication.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use Mcp\Client\Transport\HttpTransport;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
Expand Down Expand Up @@ -83,6 +84,9 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM
echo "Connecting to MCP server at {$endpoint}...\n";
$client->connect($transport);

// Ask for log messages: from 2026-07-28 on a server sends none unless asked.
$client->setLoggingLevel(LoggingLevel::Info);

$serverInfo = $client->getServerInfo();
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";

Expand Down
4 changes: 4 additions & 0 deletions examples/client/stdio_client_communication.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Mcp\Client\Transport\StdioTransport;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
Expand Down Expand Up @@ -75,6 +76,9 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM
echo "Connecting to MCP server...\n";
$client->connect($transport);

// Ask for log messages: from 2026-07-28 on a server sends none unless asked.
$client->setLoggingLevel(LoggingLevel::Info);

$serverInfo = $client->getServerInfo();
echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n";

Expand Down
8 changes: 5 additions & 3 deletions examples/client/stdio_roots.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
* STDIO Roots Example.
*
* This example demonstrates the "roots" client capability:
* - The client advertises the `roots` capability during initialization.
* - The client declares the `roots` capability.
* - It answers server `roots/list` requests via a RootsCallbackInterface,
* exposing a couple of `file://` workspace folders.
* - Calling the server's `inspect_workspace_roots` tool makes the server issue
* a `roots/list` request, so the handler below actually runs.
* - It notifies the server when its list of roots changes.
* - It notifies the server when its list of roots changes, which only a
* connection on the handshake era (before 2026-07-28) sends.
*
* Usage: php examples/client/stdio_roots.php
*/
Expand Down Expand Up @@ -78,7 +79,8 @@ public function __invoke(ListRootsRequest $request): ListRootsResult
}

// Whenever the client's workspace folders change, notify the server so it can
// request an updated list via roots/list.
// request an updated list via roots/list. On 2026-07-28 this sends nothing: the
// server asks for the roots again with each call that needs them.
echo "\nNotifying the server that the roots list changed...\n";
$client->sendRootsListChanged();

Expand Down
4 changes: 2 additions & 2 deletions src/Client/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public function setClientInfo(string $name, string $version, ?string $descriptio
}

/**
* Set the protocol version the client prefers, defaults to 2025-11-25; a modern one is probed for first.
* Set the protocol version the client prefers, defaults to 2026-07-28; a modern one is probed for first.
*/
public function setProtocolVersion(ProtocolVersion $protocolVersion): self
{
Expand Down Expand Up @@ -209,7 +209,7 @@ 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,
Expand Down
2 changes: 1 addition & 1 deletion src/Client/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Configuration
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,
Expand Down
2 changes: 1 addition & 1 deletion src/Schema/JsonRpc/MessageInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
interface MessageInterface extends \JsonSerializable
{
public const JSONRPC_VERSION = '2.0';
public const PROTOCOL_VERSION = ProtocolVersion::V2025_11_25;
public const PROTOCOL_VERSION = ProtocolVersion::V2026_07_28;
}
3 changes: 1 addition & 2 deletions src/Schema/Result/InitializeResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\MessageInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\ServerCapabilities;
Expand Down Expand Up @@ -91,7 +90,7 @@ public static function fromArray(array $data): self
*/
public function jsonSerialize(): array
{
$protocolVersion = $this->protocolVersion ?? MessageInterface::PROTOCOL_VERSION;
$protocolVersion = $this->protocolVersion ?? ProtocolVersion::latestHandshake();
$data = [
'protocolVersion' => $protocolVersion->value,
'capabilities' => $this->capabilities,
Expand Down
6 changes: 4 additions & 2 deletions tests/Integration/ElicitationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public function testDeclinedElicitation(): void
public function testCapabilityIsVisibleToTheServer(): void
{
// The tool consults supportsElicitation(), which answers from the
// capabilities this client sent during the handshake.
// capabilities this client declared.
$client = $this->connect('elicitation');

$result = $client->callTool('ask_name');
Expand All @@ -80,7 +80,9 @@ public function testAdvertisedCapabilityWithoutHandler(): void
// the tool as a ClientException rather than leaving it waiting.
$client = $this->connect(
'elicitation',
$this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)),
$this->clientBuilder()
->setProtocolVersion(ProtocolVersion::V2025_11_25)
->setCapabilities(new ClientCapabilities(elicitation: true)),
);

$result = $client->callTool('ask_name');
Expand Down
20 changes: 20 additions & 0 deletions tests/Integration/Fixture/roots.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* Server for {@see \Mcp\Tests\Integration\RootsTest}.
*/

use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server;
use Mcp\Server\RequestContext;
use Mcp\Server\Transport\StdioTransport;
Expand All @@ -39,5 +41,23 @@ static function (RequestContext $context): string {
name: 'inspect_roots',
description: 'Reports the workspace roots the client exposes.',
)
->addTool(
static function (RequestContext $context): string|InputRequiredResult {
$result = $context->getInputContext()?->rootsResult('roots');

if (null === $result) {
return new InputRequiredResult(['roots' => new ListRootsRequest()]);
}

$described = [];
foreach ($result->roots as $root) {
$described[] = sprintf('%s (%s)', $root->uri, $root->name ?? '-');
}

return implode(', ', $described);
},
name: 'inspect_roots_by_asking',
description: 'Reports the workspace roots by returning a roots ask.',
)
->build()
->run(new StdioTransport());
22 changes: 22 additions & 0 deletions tests/Integration/Fixture/sampling.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
*/

use Mcp\Exception\ClientException;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server;
use Mcp\Server\ClientGateway;
use Mcp\Server\RequestContext;
Expand Down Expand Up @@ -54,5 +58,23 @@ static function (ClientGateway $client, string $text): string {
name: 'summarize_via_gateway',
description: 'Summarizes text through a directly injected gateway.',
)
->addTool(
static function (RequestContext $context, string $text): string|InputRequiredResult {
$result = $context->getInputContext()?->samplingResult('summary');

if (null === $result) {
return new InputRequiredResult(['summary' => new CreateSamplingMessageRequest(
messages: [new SamplingMessage(Role::User, new TextContent($text))],
maxTokens: 64,
)]);
}

assert($result->content instanceof TextContent);

return sprintf('%s said: %s', $result->model, $result->content->text);
},
name: 'summarize_by_asking',
description: 'Summarizes text by returning a sampling ask.',
)
->build()
->run(new StdioTransport());
6 changes: 4 additions & 2 deletions tests/Integration/HandshakeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?Protocol
*/
public static function provideNegotiations(): iterable
{
yield 'both unconfigured' => [null, null, ProtocolVersion::latestHandshake()];
// Both ends speak both eras, so they settle on the modern one.
yield 'both unconfigured' => [null, null, ProtocolVersion::V2026_07_28];
yield 'server without the modern era, client unconfigured' => [null, null, ProtocolVersion::V2025_11_25, true];

// Whichever end of the supported range it sits at.
foreach (ProtocolVersion::handshakeVersions() as $version) {
Expand Down Expand Up @@ -140,7 +142,7 @@ private static function environment(?ProtocolVersion $serverVersion, bool $hands
return $env;
}

#[TestDox('the handshake carries the server capabilities to the client')]
#[TestDox('the server capabilities reach the client on connect')]
public function testServerCapabilitiesAreExchanged(): void
{
$capabilities = $this->connect('handshake')->getServerCapabilities();
Expand Down
30 changes: 29 additions & 1 deletion tests/Integration/RootsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Mcp\Client\Handler\Request\RootsCallbackInterface;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\ListRootsResult;
use Mcp\Schema\Root;
Expand All @@ -28,6 +29,15 @@
*/
final class RootsTest extends IntegrationTestCase
{
/**
* The gateway call-out exists only on the handshake era, so a client and
* server that could both settle on the modern era are kept off it.
*/
protected function clientBuilder(): ClientBuilder
{
return parent::clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25);
}

#[TestDox('the roots the client exposes reach the tool that asked')]
public function testRootsReachTheTool(): void
{
Expand Down Expand Up @@ -77,7 +87,25 @@ public function testRootsListChangedNotification(): void
$this->assertInstanceOf(TextContent::class, $client->callTool('inspect_roots')->content[0]);
}

#[TestDox('a roots ask the tool returns is answered on the modern era a default client settles on')]
public function testReturnedAskIsAnsweredOnTheModernEra(): void
{
$client = $this->connect('roots', $this->exposing(parent::clientBuilder(), new Root('file:///workspace/app', 'App')));

$this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion());

$result = $client->callTool('inspect_roots_by_asking');

$this->assertInstanceOf(TextContent::class, $result->content[0]);
$this->assertSame('file:///workspace/app (App)', $result->content[0]->text);
}

private function clientExposing(Root ...$roots): ClientBuilder
{
return $this->exposing($this->clientBuilder(), ...$roots);
}

private function exposing(ClientBuilder $builder, Root ...$roots): ClientBuilder
{
$callback = new class(array_values($roots)) implements RootsCallbackInterface {
/** @param list<Root> $roots */
Expand All @@ -91,7 +119,7 @@ public function __invoke(ListRootsRequest $request): ListRootsResult
}
};

return $this->clientBuilder()
return $builder
->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true))
->addRequestHandler(new ListRootsRequestHandler($callback));
}
Expand Down
Loading
Loading