[Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks (#429)

* [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.

* Drop ServerExtensionInterface in favour of ExtensionInterface
This commit is contained in:
Christopher Hertel 2026-08-18 01:46:53 +02:00 • committed by GitHub
commit 6f58fe596a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 273 additions and 21 deletions

View file

@ -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. [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.

View file

@ -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 |

View file

@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\McpApps;
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 @@ final class WeatherApp
);
}
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 @@ final class WeatherApp
$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;
}
}

View file

@ -14,8 +14,10 @@ namespace Mcp\Client;
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<string, array<string, mixed>> */
private array $extensions = [];
private int $initTimeout = 30;
private int $requestTimeout = 120;
private int $maxRetries = 3;
@ -79,6 +84,27 @@ final class Builder
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 @@ final class Builder
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,

View file

@ -125,6 +125,25 @@ class ClientCapabilities implements \JsonSerializable
return false;
}
/**
* @param array<string, mixed> $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,

View file

@ -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 @@ use Mcp\Schema\Extension\ServerExtensionInterface;
* 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 <mail@christopher-hertel.de>
*/
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';

View file

@ -0,0 +1,41 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Extension;
/**
* An 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[<id>]`. 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 <mail@christopher-hertel.de>
*/
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[<id>]`, so every value must be JSON-serializable
* (scalars, arrays, or `JsonSerializable` objects).
*
* @return array<string, mixed>
*/
public function getCapabilities(): array;
}

View file

@ -32,7 +32,7 @@ use Mcp\Exception\LogicException;
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 @@ final class Builder
*
* @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();

View file

@ -349,6 +349,21 @@ class ClientGateway
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

View file

@ -15,6 +15,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider;
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 @@ class DiscoveryTest extends TestCase
$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 @@ class DiscoveryTest extends TestCase
$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 @@ class DiscoveryTest extends TestCase
$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);

View file

@ -0,0 +1,53 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Extension\Apps\McpApps;
use Mcp\Schema\Extension\Apps\ToolVisibility;
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
use Mcp\Schema\Extension\Apps\UiResourceCsp;
use Mcp\Schema\Extension\Apps\UiToolMeta;
/**
* An MCP App declared purely through attributes: a `ui://` resource flagged with the
* `_meta.ui` marker and a tool linked to it via `UiToolMeta`.
*/
class DiscoverableUiHandler
{
#[McpResource(
uri: 'ui://widget/clock',
name: 'clock_widget',
mimeType: McpApps::MIME_TYPE,
meta: ['ui' => new \stdClass()],
)]
public function widget(): TextResourceContents
{
return new TextResourceContents(
uri: 'ui://widget/clock',
mimeType: McpApps::MIME_TYPE,
text: '<html></html>',
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;
}
}

View file

@ -13,6 +13,9 @@ namespace Mcp\Tests\Unit\Client;
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 @@ final class BuilderTest extends TestCase
$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);

View file

@ -17,14 +17,16 @@ use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
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());
}

View file

@ -113,6 +113,17 @@ class CapabilitiesExtensionsTest extends TestCase
$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 = [

View file

@ -18,6 +18,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
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 @@ final class ClientGatewayTest extends TestCase
$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());