From b414f781e03b2b110ac3d72f9266e90a3551f159 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 22:16:54 +0200 Subject: [PATCH] [Client] Expose the capabilities the server declared The client parsed the server's capabilities during the handshake but kept none of them. Keep them in the client state and expose them through Client::getServerCapabilities(), read from server/discover on 2026-07-28. --- CHANGELOG.md | 2 ++ src/Client.php | 13 +++++++++++++ src/Client/Protocol.php | 13 ++++++++++--- src/Client/State/ClientState.php | 12 ++++++++++++ src/Client/State/ClientStateInterface.php | 11 +++++++++++ tests/Integration/HandshakeTest.php | 20 ++++++++++++++++++++ tests/Integration/StatelessClientTest.php | 1 + tests/Unit/Client/ProtocolTest.php | 1 + 8 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9a859a..1bf3d225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `AbstractSchemaDefinition` declares an abstract `getDefault()`, which a custom schema definition has to implement. * Fix `Client::getPrompt()` without arguments sending `"arguments": []`, which servers validating the spec's object type (e.g. the TypeScript SDK) reject: empty arguments are now omitted. * Add `Tool::$execution` (`ToolExecution` with a `TaskSupport` enum) and `ServerCapabilities::$tasks`, which were dropped when parsing a 2025-11-25 server's `tools/list` and `initialize` results. +* Add `Client::getServerCapabilities()`, returning what the server declared in `initialize` or, from `2026-07-28` on, in `server/discover`. +* [BC Break] `ClientStateInterface` declares `setServerCapabilities()` and `getServerCapabilities()`, which a custom implementation has to add. 0.8.0 ----- diff --git a/src/Client.php b/src/Client.php index 143ab206..5e40cad2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -48,6 +48,7 @@ use Mcp\Schema\Result\ListResourceTemplatesResult; use Mcp\Schema\Result\ListToolsResult; use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Schema\ServerCapabilities; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -147,6 +148,18 @@ public function getInstructions(): ?string return $this->protocol->getState()->getInstructions(); } + /** + * Capabilities the server declared: during the handshake, or in its + * `server/discover` result from 2026-07-28 on. + * + * Null until a server declared them, e.g. before connecting or when a + * 2026-07-28 server does not answer `server/discover`. + */ + public function getServerCapabilities(): ?ServerCapabilities + { + return $this->protocol->getState()->getServerCapabilities(); + } + /** * Protocol revision negotiated during the handshake. * diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 2d71c00a..251826c7 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -38,6 +38,7 @@ use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; +use Mcp\Schema\ServerCapabilities; use Mcp\Server\Stateless\RequestMeta; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -228,6 +229,7 @@ public function initialize(Configuration $config): Response|Error $this->state->setProtocolVersion($negotiated); $this->state->setServerInfo($initResult->serverInfo); $this->state->setInstructions($initResult->instructions); + $this->state->setServerCapabilities($initResult->capabilities); $this->state->setInitialized(true); $this->sendNotification(new InitializedNotification()); @@ -246,9 +248,10 @@ public function initialize(Configuration $config): Response|Error * * There is nothing to negotiate: the revision travels on every request, so * the connection is usable the moment the transport is. `server/discover` - * is only asked because the facade exposes `getServerInfo()`, and a server - * that will not answer it still serves every other method — so a failure - * here is logged and the connection proceeds. + * is only asked because the facade exposes `getServerInfo()` and + * `getServerCapabilities()`, and a server that will not answer it still + * serves every other method — so a failure here is logged and the + * connection proceeds. * * @return Response> */ @@ -300,6 +303,10 @@ private function readDiscovery(array $result): void $this->state->setInstructions($result['instructions']); } + if (\is_array($result['capabilities'] ?? null)) { + $this->state->setServerCapabilities(ServerCapabilities::fromArray($result['capabilities'])); + } + $this->reconcileVersion($result['supportedVersions'] ?? null); $this->logger->info('Discovery complete', [ diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php index a1e1f896..8fb86593 100644 --- a/src/Client/State/ClientState.php +++ b/src/Client/State/ClientState.php @@ -15,6 +15,7 @@ use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\ServerCapabilities; /** * In-memory client state implementation. @@ -32,6 +33,7 @@ class ClientState implements ClientStateInterface private ?ProtocolVersion $protocolVersion = null; private ?Implementation $serverInfo = null; private ?string $instructions = null; + private ?ServerCapabilities $serverCapabilities = null; /** @var array */ private array $pendingRequests = []; @@ -128,6 +130,16 @@ public function getInstructions(): ?string return $this->instructions; } + public function setServerCapabilities(ServerCapabilities $capabilities): void + { + $this->serverCapabilities = $capabilities; + } + + public function getServerCapabilities(): ?ServerCapabilities + { + return $this->serverCapabilities; + } + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void { $this->progressUpdates[] = [ diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php index a5de42d9..64276279 100644 --- a/src/Client/State/ClientStateInterface.php +++ b/src/Client/State/ClientStateInterface.php @@ -15,6 +15,7 @@ use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\ServerCapabilities; /** * Interface for client state management. @@ -109,6 +110,16 @@ public function setInstructions(?string $instructions): void; */ public function getInstructions(): ?string; + /** + * Set the capabilities the server declared. + */ + public function setServerCapabilities(ServerCapabilities $capabilities): void; + + /** + * Get the capabilities the server declared, null until it declared them. + */ + public function getServerCapabilities(): ?ServerCapabilities; + /** * Store progress data received from a notification. * diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5d213209..99ff4647 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -82,6 +82,26 @@ public function testServerInfoIsExchanged(): void $this->assertTrue($client->isConnected()); } + #[TestDox('the handshake carries the server capabilities to the client')] + public function testServerCapabilitiesAreExchanged(): void + { + $capabilities = $this->connect('handshake')->getServerCapabilities(); + + // The fixture registers no tools, resources or prompts. + $this->assertNotNull($capabilities); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->prompts); + $this->assertFalse($capabilities->resources); + } + + #[TestDox('the server capabilities are unset before the handshake')] + public function testServerCapabilitiesAreNullBeforeConnecting(): void + { + $this->assertNull($this->clientBuilder()->build()->getServerCapabilities()); + } + #[TestDox('the negotiated revision is unset before the handshake')] public function testProtocolVersionIsNullBeforeConnecting(): void { diff --git a/tests/Integration/StatelessClientTest.php b/tests/Integration/StatelessClientTest.php index fbd3d59e..7bf94699 100644 --- a/tests/Integration/StatelessClientTest.php +++ b/tests/Integration/StatelessClientTest.php @@ -75,6 +75,7 @@ public function testConnectWithoutHandshake(): void $this->assertTrue($client->isConnected()); $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); $this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name); + $this->assertTrue($client->getServerCapabilities()?->tools); $client->disconnect(); } diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index d2611c02..ad250342 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -93,6 +93,7 @@ public function testDiscoveryFailureIsNotFatal(): void $protocol->initialize($config); $this->assertTrue($protocol->getState()->isInitialized()); + $this->assertNull($protocol->getState()->getServerCapabilities()); } #[TestDox('refuses to continue when discovery shows the server has no modern revision')]