From 1de52d23425a4d0174f7a17ee846214e50274b15 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 01:32:46 +0200 Subject: [PATCH 1/2] [Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks Add ClientGateway::supportsExtension(), Client\Builder::enableExtension() and ClientCapabilities::withExtensions(); introduce side-agnostic ExtensionInterface and deprecate ServerExtensionInterface; document attribute-based UI linkage. --- CHANGELOG.md | 1 + docs/extensions.md | 55 +++++++++++++++++-- examples/server/mcp-apps/WeatherApp.php | 13 ++++- src/Client/Builder.php | 36 +++++++++++- src/Schema/ClientCapabilities.php | 19 +++++++ src/Schema/Extension/Apps/McpApps.php | 7 ++- src/Schema/Extension/ExtensionInterface.php | 41 ++++++++++++++ .../Extension/ServerExtensionInterface.php | 23 +------- src/Server/Builder.php | 4 +- src/Server/ClientGateway.php | 15 +++++ .../Capability/Discovery/DiscoveryTest.php | 13 ++++- .../Fixtures/DiscoverableUiHandler.php | 53 ++++++++++++++++++ tests/Unit/Client/BuilderTest.php | 38 +++++++++++++ .../Schema/Extension/Apps/McpAppsTest.php | 4 +- .../Extension/CapabilitiesExtensionsTest.php | 11 ++++ tests/Unit/Server/ClientGatewayTest.php | 12 ++++ 16 files changed, 309 insertions(+), 36 deletions(-) create mode 100644 src/Schema/Extension/ExtensionInterface.php create mode 100644 tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbabda1..f16c73bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. `ServerExtensionInterface` is deprecated in favour of the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. diff --git a/docs/extensions.md b/docs/extensions.md index f1817026..aff06aba 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -13,14 +13,37 @@ $server = Server::builder() ->build(); ``` -Pass one or more `ServerExtensionInterface` instances; multiple extensions can +Pass one or more `ExtensionInterface` instances; multiple extensions can be enabled in a single call. Enabling the same extension twice throws a `LogicException`. +Clients (hosts) advertise the extensions they support the same way, via +`Client\Builder::enableExtension()`; the payload lands under +`capabilities.extensions` in the initialize request. + > Note: extensions enabled via `enableExtension()` are merged into the -> `extensions` capability even when you supply your own `ServerCapabilities` via -> `setCapabilities()`. An enabled extension overrides any entry under the same -> id already present in those capabilities. +> `extensions` capability even when you supply your own `ServerCapabilities` / +> `ClientCapabilities` via `setCapabilities()`. An enabled extension overrides +> any entry under the same id already present in those capabilities. + +## Checking what the client negotiated + +An extension is only in effect when both sides advertise it. On the server, a +handler can ask the `ClientGateway` from the injected `RequestContext` whether the +connected client did: + +```php +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Server\RequestContext; + +public function getWeather(string $city, RequestContext $context): string +{ + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + // text-only fallback for hosts without MCP Apps support + } + // ... +} +``` ## MCP Apps (`io.modelcontextprotocol/ui`) @@ -78,6 +101,30 @@ flagging it as an MCP App, while the resource *content* returned by `resources/r carries the structured `UiResourceContentMeta` with the actual CSP and permission configuration. +### Attribute-based discovery + +The same linkage works with `#[McpResource]` / `#[McpTool]`, since both accept a +`meta` array and PHP allows `new` in attribute arguments. The one difference is the +descriptor marker: `McpApps::resourceMarker()` is a method call and cannot appear +in an attribute, so spell it as `new \stdClass()` there. + +```php +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Extension\Apps\McpApps; +use Mcp\Schema\Extension\Apps\ToolVisibility; +use Mcp\Schema\Extension\Apps\UiToolMeta; + +final class WeatherApp +{ + #[McpResource(uri: 'ui://my-app', mimeType: McpApps::MIME_TYPE, meta: ['ui' => new \stdClass()])] + public function view(): TextResourceContents { /* as above */ } + + #[McpTool(name: 'my_tool', meta: ['ui' => new UiToolMeta(resourceUri: 'ui://my-app', visibility: [ToolVisibility::Model, ToolVisibility::App])])] + public function myTool(string $city): string { /* ... */ } +} +``` + ### Server-side DTOs | Class | Purpose | diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php index 5facead6..159e8c47 100644 --- a/examples/server/mcp-apps/WeatherApp.php +++ b/examples/server/mcp-apps/WeatherApp.php @@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\UiResourceContentMeta; use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; +use Mcp\Server\RequestContext; final class WeatherApp { @@ -39,7 +40,7 @@ public function getWeatherApp(): TextResourceContents ); } - public function getWeather(string $city): string + public function getWeather(string $city, RequestContext $context): string { $weather = [ 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'], @@ -56,12 +57,20 @@ public function getWeather(string $city): string $key = strtolower($city); $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%']; - return \sprintf( + $text = \sprintf( 'Weather in %s: %s, %s, Humidity: %s', $city, $data['temp'], $data['condition'], $data['humidity'], ); + + // Hosts without MCP Apps support only ever see this text; the ones that + // negotiated the extension render the ui://weather-app view on top of it. + if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) { + return $text.' (text-only, host does not support MCP Apps)'; + } + + return $text; } } diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 7c396176..bfed70f6 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -14,8 +14,10 @@ use Mcp\Client; use Mcp\Client\Handler\Notification\NotificationHandlerInterface; use Mcp\Client\Handler\Request\RequestHandlerInterface; +use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Implementation; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -33,6 +35,9 @@ final class Builder private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; private ?ClientCapabilities $capabilities = null; + + /** @var array> */ + private array $extensions = []; private int $initTimeout = 30; private int $requestTimeout = 120; private int $maxRetries = 3; @@ -79,6 +84,27 @@ public function setCapabilities(ClientCapabilities $capabilities): self return $this; } + /** + * Enable one or more MCP protocol extensions, announced to the server under + * `capabilities.extensions` in the initialize request. + * + * @throws LogicException if the same extension is enabled more than once + */ + public function enableExtension(ExtensionInterface ...$extensions): self + { + foreach ($extensions as $extension) { + $id = $extension->getId(); + + if (isset($this->extensions[$id])) { + throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); + } + + $this->extensions[$id] = $extension->getCapabilities(); + } + + return $this; + } + /** * Set initialization timeout in seconds. */ @@ -159,9 +185,17 @@ public function build(): Client title: $this->title, ); + $capabilities = $this->capabilities ?? new ClientCapabilities(); + + // Extensions enabled via enableExtension() are folded into caller-supplied + // capabilities too, so setCapabilities() does not silently drop them. + if ([] !== $this->extensions) { + $capabilities = $capabilities->withExtensions($this->extensions); + } + $config = new Configuration( clientInfo: $clientInfo, - capabilities: $this->capabilities ?? new ClientCapabilities(), + capabilities: $capabilities, protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index b2a953e8..bc71cfc2 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -125,6 +125,25 @@ private static function namesMode(mixed $capability, string $name): bool return false; } + /** + * @param array $extensions + */ + public function withExtensions(array $extensions): self + { + return new self( + $this->roots, + $this->rootsListChanged, + $this->sampling, + $this->elicitation, + $this->experimental, + array_replace($this->extensions ?? [], $extensions), + $this->samplingContext, + $this->samplingTools, + $this->elicitationForm, + $this->elicitationUrl, + ); + } + /** * @return array{ * roots?: object, diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php index bd702c87..a9124a49 100644 --- a/src/Schema/Extension/Apps/McpApps.php +++ b/src/Schema/Extension/Apps/McpApps.php @@ -11,7 +11,7 @@ namespace Mcp\Schema\Extension\Apps; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\ExtensionInterface; /** * The MCP Apps extension (io.modelcontextprotocol/ui). @@ -19,13 +19,14 @@ * MCP Apps allows servers to expose interactive HTML UI applications as resources. * Clients that support the extension render these in sandboxed iframes. * - * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}. + * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()} and on the + * client (host) via {@see \Mcp\Client\Builder::enableExtension()}. * * @see https://github.com/modelcontextprotocol/ext-apps * * @author Christopher Hertel */ -final class McpApps implements ServerExtensionInterface +final class McpApps implements ExtensionInterface { public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; public const MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/Schema/Extension/ExtensionInterface.php b/src/Schema/Extension/ExtensionInterface.php new file mode 100644 index 00000000..f8a4038f --- /dev/null +++ b/src/Schema/Extension/ExtensionInterface.php @@ -0,0 +1,41 @@ +]`. The same + * extension object can be enabled on a server (initialize response) and on a client + * (initialize request); the side that enables it decides which. + * + * @author Christopher Hertel + */ +interface ExtensionInterface +{ + /** + * The reverse-DNS identifier used as the key under `capabilities.extensions`. + */ + public function getId(): string; + + /** + * The capability payload announced for this extension. + * + * The returned array is cast to an object and embedded under + * `capabilities.extensions[]`, so every value must be JSON-serializable + * (scalars, arrays, or `JsonSerializable` objects). + * + * @return array + */ + public function getCapabilities(): array; +} diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php index 8793a966..1e8fc06a 100644 --- a/src/Schema/Extension/ServerExtensionInterface.php +++ b/src/Schema/Extension/ServerExtensionInterface.php @@ -12,29 +12,10 @@ namespace Mcp\Schema\Extension; /** - * A server-side MCP protocol extension advertised during capability negotiation. - * - * Implementations are typically zero-config — they expose a stable identifier and the - * capability payload announced under `capabilities.extensions[]` in the initialize - * response. + * @deprecated since 0.8, use {@see ExtensionInterface} instead — extensions are no longer server-only * * @author Christopher Hertel */ -interface ServerExtensionInterface +interface ServerExtensionInterface extends ExtensionInterface { - /** - * The reverse-DNS identifier used as the key under `capabilities.extensions`. - */ - public function getId(): string; - - /** - * The capability payload announced for this extension. - * - * The returned array is cast to an object and embedded under - * `capabilities.extensions[]` in the initialize response, so every value - * must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects). - * - * @return array - */ - public function getCapabilities(): array; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index dca38ef1..c01dd731 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,7 +32,7 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; -use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -282,7 +282,7 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self * * @throws LogicException if the same extension is enabled more than once */ - public function enableExtension(ServerExtensionInterface ...$extensions): self + public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { $id = $extension->getId(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index b696ff79..2df6fa36 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -349,6 +349,21 @@ public function supportsElicitationUrl(): bool return $this->hasSubCapability('elicitation', 'url'); } + /** + * Check if the connected client negotiated the given protocol extension. + * + * Extensions are advertised under `capabilities.extensions` keyed by their + * reverse-DNS id, e.g. `McpApps::EXTENSION_ID` — a server offering MCP Apps + * should check this before pointing a tool at a `ui://` resource, and fall back + * to a text-only result otherwise. + * + * @return bool True if the client advertised the extension, false otherwise + */ + public function supportsExtension(string $id): bool + { + return $this->hasSubCapability('extensions', $id); + } + /** * Sub-capabilities are declared by the presence of a (possibly empty) object, so * only the key matters — not whatever it holds. The value arrives as an object on diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index b4197262..5c716d7d 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Registry\ToolReference; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; @@ -38,7 +39,7 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); $tools = $discovery->getTools(); - $this->assertCount(5, $tools); + $this->assertCount(6, $tools); $this->assertArrayHasKey('greet_user', $tools); $this->assertEquals('greet_user', $tools['greet_user']->tool->name); @@ -63,7 +64,7 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); $resources = $discovery->getResources(); - $this->assertCount(3, $resources); + $this->assertCount(4, $resources); $this->assertArrayHasKey('app://info/version', $resources); $this->assertEquals('app_version', $resources['app://info/version']->resource->name); @@ -72,6 +73,14 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertArrayHasKey('invokable://config/status', $resources); $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); + $this->assertArrayHasKey('ui://widget/clock', $resources); + $this->assertEquals(McpApps::MIME_TYPE, $resources['ui://widget/clock']->resource->mimeType); + $this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta)); + $this->assertJsonStringEqualsJsonString( + '{"ui":{"resourceUri":"ui://widget/clock","visibility":["app"]}}', + json_encode($tools['show_clock']->tool->meta), + ); + $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php new file mode 100644 index 00000000..f5d03ed3 --- /dev/null +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableUiHandler.php @@ -0,0 +1,53 @@ + new \stdClass()], + )] + public function widget(): TextResourceContents + { + return new TextResourceContents( + uri: 'ui://widget/clock', + mimeType: McpApps::MIME_TYPE, + text: '', + meta: ['ui' => new UiResourceContentMeta(csp: new UiResourceCsp(connectDomains: ['https://time.example.com']))], + ); + } + + #[McpTool( + name: 'show_clock', + meta: ['ui' => new UiToolMeta(resourceUri: 'ui://widget/clock', visibility: [ToolVisibility::App])], + )] + public function showClock(string $timezone): string + { + return $timezone; + } +} diff --git a/tests/Unit/Client/BuilderTest.php b/tests/Unit/Client/BuilderTest.php index cd0bdf78..b462b9bb 100644 --- a/tests/Unit/Client/BuilderTest.php +++ b/tests/Unit/Client/BuilderTest.php @@ -13,6 +13,9 @@ use Mcp\Client; use Mcp\Client\Configuration; +use Mcp\Exception\LogicException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Extension\Apps\McpApps; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -43,6 +46,41 @@ public function testSetClientInfoTitleIsOptional(): void $this->assertNull($this->extractConfiguration($client)->clientInfo->title); } + #[TestDox('enableExtension() announces the extension under capabilities.extensions')] + public function testEnableExtensionAdvertisesExtension(): void + { + $client = Client::builder() + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertSame([McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]], $capabilities->extensions); + } + + #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] + public function testEnableExtensionMergesIntoCustomCapabilities(): void + { + $client = Client::builder() + ->setCapabilities(new ClientCapabilities(elicitation: true, extensions: ['io.example/other' => []])) + ->enableExtension(new McpApps()) + ->build(); + + $capabilities = $this->extractConfiguration($client)->capabilities; + + $this->assertTrue($capabilities->elicitation); + $this->assertSame(['io.example/other', McpApps::EXTENSION_ID], array_keys($capabilities->extensions ?? [])); + } + + #[TestDox('enableExtension() throws when the same extension is enabled twice')] + public function testEnableExtensionRejectsDuplicate(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(McpApps::EXTENSION_ID); + + Client::builder()->enableExtension(new McpApps(), new McpApps()); + } + private function extractConfiguration(Client $client): Configuration { $config = (new \ReflectionClass($client))->getProperty('config')->getValue($client); diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index ec0fd2ba..cdb8237e 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -17,14 +17,16 @@ use Mcp\Schema\Extension\Apps\UiResourceCsp; use Mcp\Schema\Extension\Apps\UiResourcePermissions; use Mcp\Schema\Extension\Apps\UiToolMeta; +use Mcp\Schema\Extension\ExtensionInterface; use PHPUnit\Framework\TestCase; class McpAppsTest extends TestCase { - public function testServerExtensionInterface(): void + public function testExtensionInterface(): void { $extension = new McpApps(); + $this->assertInstanceOf(ExtensionInterface::class, $extension); $this->assertSame('io.modelcontextprotocol/ui', $extension->getId()); $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); } diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php index d9661115..7ef916ec 100644 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -113,6 +113,17 @@ public function testServerCapabilitiesWithExtensionsMerges(): void $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); } + public function testClientCapabilitiesWithExtensionsMerges(): void + { + $caps = new ClientCapabilities(elicitation: true, extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]]); + + $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); + + $this->assertTrue($merged->elicitation); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 99], 'c' => ['z' => 3]], $merged->extensions); + $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); + } + public function testClientCapabilitiesWithExtensions(): void { $extensions = [ diff --git a/tests/Unit/Server/ClientGatewayTest.php b/tests/Unit/Server/ClientGatewayTest.php index 61f5eb02..2b70fd6e 100644 --- a/tests/Unit/Server/ClientGatewayTest.php +++ b/tests/Unit/Server/ClientGatewayTest.php @@ -18,6 +18,7 @@ use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\ElicitAction; use Mcp\Schema\Enum\ElicitationMode; +use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -86,6 +87,17 @@ public function testSupportsSamplingToolsReadsTheObjectShapeToo(): void $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools()); } + public function testSupportsExtensionReflectsTheNegotiatedExtensions(): void + { + $this->assertTrue($this->gatewayFor(['extensions' => [McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor(['extensions' => ['io.example/other' => []]])->supportsExtension(McpApps::EXTENSION_ID)); + $this->assertFalse($this->gatewayFor([])->supportsExtension(McpApps::EXTENSION_ID)); + + // Live sessions hold the serialized object shape. + $capabilities = (new ClientCapabilities(extensions: [McpApps::EXTENSION_ID => (new McpApps())->getCapabilities()]))->jsonSerialize(); + $this->assertTrue($this->gatewayFor((array) $capabilities)->supportsExtension(McpApps::EXTENSION_ID)); + } + public function testSupportsElicitationUrlReflectsTheSubCapability(): void { $this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl()); From fe904c8007467daaa4b26b985cef2794a9e4f9ee Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 01:40:13 +0200 Subject: [PATCH 2/2] Drop ServerExtensionInterface in favour of ExtensionInterface --- CHANGELOG.md | 2 +- .../Extension/ServerExtensionInterface.php | 21 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 src/Schema/Extension/ServerExtensionInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f16c73bb..59c5324f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- -* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. `ServerExtensionInterface` is deprecated in favour of the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. +* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal. * Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. * Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before. diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php deleted file mode 100644 index 1e8fc06a..00000000 --- a/src/Schema/Extension/ServerExtensionInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ -interface ServerExtensionInterface extends ExtensionInterface -{ -}