Skip to content

Commit 2740916

Browse files
chr-hertelclaude
andcommitted
Apply PR review feedback for MCP Apps extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0001d18 commit 2740916

14 files changed

Lines changed: 134 additions & 45 deletions

File tree

docs/extensions.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ use Mcp\Server;
99

1010
$server = Server::builder()
1111
->setServerInfo('My Server', '1.0.0')
12-
->enableExtension(McpApps::class) // or pre-built instances
12+
->enableExtension(new McpApps())
1313
->build();
1414
```
1515

16-
Pass either a class string (the extension is instantiated with no arguments) or
17-
a pre-built `ServerExtensionInterface` instance. Multiple extensions can be
18-
enabled in a single call.
16+
Pass one or more `ServerExtensionInterface` instances; multiple extensions can
17+
be enabled in a single call. Enabling the same extension twice throws a
18+
`LogicException`.
1919

20-
> Note: calling `setCapabilities()` overrides automatic capability detection,
21-
> so it also overrides the `extensions` field. If you set your own
22-
> `ServerCapabilities`, include the extensions you want yourself.
20+
> Note: extensions enabled via `enableExtension()` are merged into the
21+
> `extensions` capability even when you supply your own `ServerCapabilities` via
22+
> `setCapabilities()`. An enabled extension overrides any entry under the same
23+
> id already present in those capabilities.
2324
2425
## MCP Apps (`io.modelcontextprotocol/ui`)
2526

@@ -44,7 +45,7 @@ use Mcp\Schema\Extension\Apps\UiResourcePermissions;
4445
use Mcp\Schema\Extension\Apps\UiToolMeta;
4546

4647
$server = Server::builder()
47-
->enableExtension(McpApps::class)
48+
->enableExtension(new McpApps())
4849
->addResource(
4950
fn () => new TextResourceContents(
5051
uri: 'ui://my-app',
@@ -58,7 +59,7 @@ $server = Server::builder()
5859
),
5960
'ui://my-app',
6061
mimeType: McpApps::MIME_TYPE,
61-
meta: ['ui' => new \stdClass()],
62+
meta: ['ui' => McpApps::resourceMarker()],
6263
)
6364
->addTool(
6465
$myToolHandler,
@@ -71,6 +72,12 @@ $server = Server::builder()
7172
->build();
7273
```
7374

75+
Note the two distinct `_meta.ui` shapes: the resource *descriptor* (its
76+
`resources/list` entry) carries only an empty marker — `McpApps::resourceMarker()`
77+
flagging it as an MCP App, while the resource *content* returned by `resources/read`
78+
carries the structured `UiResourceContentMeta` with the actual CSP and permission
79+
configuration.
80+
7481
### Server-side DTOs
7582

7683
| Class | Purpose |

examples/server/mcp-apps/server.php

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,14 @@
2424
$server = Server::builder()
2525
->setServerInfo('MCP Apps Weather Example', '1.0.0')
2626
->setLogger(logger())
27-
->enableExtension(McpApps::class)
27+
->enableExtension(new McpApps())
2828
->addResource(
2929
[WeatherApp::class, 'getWeatherApp'],
3030
'ui://weather-app',
3131
'weather-app',
3232
description: 'Interactive weather dashboard',
3333
mimeType: McpApps::MIME_TYPE,
34-
// Empty `ui` marker on the resource descriptor flags it as an MCP App in
35-
// resources/list. The CSP/permissions live on the resource *content* (_meta.ui
36-
// in resources/read), set via UiResourceContentMeta in WeatherApp::getWeatherApp().
37-
meta: ['ui' => new stdClass()],
34+
meta: ['ui' => McpApps::resourceMarker()],
3835
)
3936
->addTool(
4037
[WeatherApp::class, 'getWeather'],

examples/server/mcp-apps/weather-app.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@
129129
let requestId = 0;
130130
const pending = new Map();
131131

132+
// Target origin is '*' because the iframe cannot know its parent's origin
133+
// upfront; the host is responsible for validating event.origin on its side.
132134
function post(msg) { window.parent.postMessage(msg, '*'); }
133135

134136
function sendRpc(method, params) {

src/Schema/ClientCapabilities.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ public static function fromArray(array $data): self
7171
$rootsListChanged,
7272
$sampling,
7373
$elicitation,
74-
$data['experimental'] ?? null,
75-
$data['extensions'] ?? null,
74+
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
75+
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
7676
);
7777
}
7878

src/Schema/Extension/Apps/McpApps.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,16 @@ public function getCapabilities(): array
4343
{
4444
return ['mimeTypes' => [self::MIME_TYPE]];
4545
}
46+
47+
/**
48+
* The marker value for the `_meta.ui` field on a UI resource *descriptor*
49+
* (its `resources/list` entry), flagging the resource as an MCP App.
50+
*
51+
* The structured CSP/permissions metadata instead belongs on the resource
52+
* *content* (the `resources/read` payload) via {@see UiResourceContentMeta}.
53+
*/
54+
public static function resourceMarker(): \stdClass
55+
{
56+
return new \stdClass();
57+
}
4658
}

src/Schema/Extension/Apps/UiResourceCsp.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,18 @@ public function jsonSerialize(): array
6262
{
6363
$data = [];
6464

65-
if (null !== $this->connectDomains) {
65+
// The MCP Apps spec (2026-01-26) defines "empty or omitted" identically
66+
// for every CSP allow-list, so empty arrays are dropped, not emitted as `[]`.
67+
if ($this->connectDomains) {
6668
$data['connectDomains'] = $this->connectDomains;
6769
}
68-
if (null !== $this->resourceDomains) {
70+
if ($this->resourceDomains) {
6971
$data['resourceDomains'] = $this->resourceDomains;
7072
}
71-
if (null !== $this->frameDomains) {
73+
if ($this->frameDomains) {
7274
$data['frameDomains'] = $this->frameDomains;
7375
}
74-
if (null !== $this->baseUriDomains) {
76+
if ($this->baseUriDomains) {
7577
$data['baseUriDomains'] = $this->baseUriDomains;
7678
}
7779

src/Schema/Extension/Apps/UiResourcePermissions.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ public function __construct(
4141
*/
4242
public static function fromArray(array $data): self
4343
{
44+
// A permission is requested when its key is present with the spec's `{}`
45+
// marker; isset() accepts that (array/object forms) and rejects a stray null.
4446
return new self(
45-
camera: \array_key_exists('camera', $data),
46-
microphone: \array_key_exists('microphone', $data),
47-
geolocation: \array_key_exists('geolocation', $data),
48-
clipboardWrite: \array_key_exists('clipboardWrite', $data),
47+
camera: isset($data['camera']),
48+
microphone: isset($data['microphone']),
49+
geolocation: isset($data['geolocation']),
50+
clipboardWrite: isset($data['clipboardWrite']),
4951
);
5052
}
5153

src/Schema/Extension/ServerExtensionInterface.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ public function getId(): string;
3030
/**
3131
* The capability payload announced for this extension.
3232
*
33+
* The returned array is cast to an object and embedded under
34+
* `capabilities.extensions[<id>]` in the initialize response, so every value
35+
* must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects).
36+
*
3337
* @return array<string, mixed>
3438
*/
3539
public function getCapabilities(): array;

src/Schema/ServerCapabilities.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,32 @@ public static function fromArray(array $data): self
109109
promptsListChanged: $promptsListChanged,
110110
logging: $loggingEnabled,
111111
completions: $completionsEnabled,
112-
experimental: $data['experimental'] ?? null,
113-
extensions: $data['extensions'] ?? null,
112+
experimental: \is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
113+
extensions: \is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
114+
);
115+
}
116+
117+
/**
118+
* Returns a copy with the given protocol extensions merged into the existing ones.
119+
*
120+
* Entries in $extensions override existing ones sharing the same id.
121+
*
122+
* @param array<string, array<string, mixed>> $extensions
123+
*/
124+
public function withExtensions(array $extensions): self
125+
{
126+
return new self(
127+
$this->tools,
128+
$this->toolsListChanged,
129+
$this->resources,
130+
$this->resourcesSubscribe,
131+
$this->resourcesListChanged,
132+
$this->prompts,
133+
$this->promptsListChanged,
134+
$this->logging,
135+
$this->completions,
136+
$this->experimental,
137+
[...$this->extensions ?? [], ...$extensions],
114138
);
115139
}
116140

src/Server/Builder.php

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Mcp\Capability\Registry\ReferenceHandlerInterface;
2727
use Mcp\Capability\RegistryInterface;
2828
use Mcp\Exception\InvalidArgumentException;
29+
use Mcp\Exception\LogicException;
2930
use Mcp\JsonRpc\MessageFactory;
3031
use Mcp\Schema\Annotations;
3132
use Mcp\Schema\Enum\ProtocolVersion;
@@ -240,22 +241,18 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self
240241
* Enable one or more MCP protocol extensions, announced to clients under
241242
* `capabilities.extensions` during the initialize handshake.
242243
*
243-
* Pass either fully qualified class names (instantiated with no arguments) or
244-
* pre-built instances.
245-
*
246-
* @param class-string<ServerExtensionInterface>|ServerExtensionInterface ...$extensions
244+
* @throws LogicException if the same extension is enabled more than once
247245
*/
248-
public function enableExtension(string|ServerExtensionInterface ...$extensions): self
246+
public function enableExtension(ServerExtensionInterface ...$extensions): self
249247
{
250248
foreach ($extensions as $extension) {
251-
if (\is_string($extension)) {
252-
if (!is_subclass_of($extension, ServerExtensionInterface::class)) {
253-
throw new InvalidArgumentException(\sprintf('Extension class "%s" must implement "%s".', $extension, ServerExtensionInterface::class));
254-
}
255-
$extension = new $extension();
249+
$id = $extension->getId();
250+
251+
if (isset($this->extensions[$id])) {
252+
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));
256253
}
257254

258-
$this->extensions[$extension->getId()] = $extension->getCapabilities();
255+
$this->extensions[$id] = $extension->getCapabilities();
259256
}
260257

261258
return $this;
@@ -621,6 +618,12 @@ public function build(): Server
621618
extensions: [] !== $this->extensions ? $this->extensions : null,
622619
);
623620

621+
// Extensions enabled via enableExtension() are folded into caller-supplied
622+
// capabilities too, so setCapabilities() does not silently drop them.
623+
if (null !== $this->serverCapabilities && [] !== $this->extensions) {
624+
$capabilities = $capabilities->withExtensions($this->extensions);
625+
}
626+
624627
$serverInfo = $this->serverInfo ?? new Implementation();
625628
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
626629
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);

0 commit comments

Comments
 (0)