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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 51 additions & 4 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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 */ }
Comment thread
chr-hertel marked this conversation as resolved.

#[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 |
Expand Down
13 changes: 11 additions & 2 deletions examples/server/mcp-apps/WeatherApp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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%'],
Expand All @@ -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)) {
Comment thread
chr-hertel marked this conversation as resolved.
return $text.' (text-only, host does not support MCP Apps)';
}

return $text;
}
}
36 changes: 35 additions & 1 deletion src/Client/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/Schema/ClientCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,25 @@ private static function namesMode(mixed $capability, string $name): bool
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,
Expand Down
7 changes: 4 additions & 3 deletions src/Schema/Extension/Apps/McpApps.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,22 @@

namespace Mcp\Schema\Extension\Apps;

use Mcp\Schema\Extension\ServerExtensionInterface;
use Mcp\Schema\Extension\ExtensionInterface;

/**
* The MCP Apps extension (io.modelcontextprotocol/ui).
*
* 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
Comment thread
chr-hertel marked this conversation as resolved.
{
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
public const MIME_TYPE = 'text/html;profile=mcp-app';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
namespace Mcp\Schema\Extension;

/**
* A server-side MCP protocol extension advertised during capability negotiation.
* 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>]` in the initialize
* response.
* 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 ServerExtensionInterface
interface ExtensionInterface
{
/**
* The reverse-DNS identifier used as the key under `capabilities.extensions`.
Expand All @@ -31,8 +32,8 @@ 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>]` in the initialize response, so every value
* must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects).
* `capabilities.extensions[<id>]`, so every value must be JSON-serializable
* (scalars, arrays, or `JsonSerializable` objects).
*
* @return array<string, mixed>
*/
Expand Down
4 changes: 2 additions & 2 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions src/Server/ClientGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions tests/Unit/Capability/Discovery/DiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);

Expand Down
Loading