[Schema][Server] Add MCP Apps extension (io.modelcontextprotocol/ui) support (#281)
* feat: add MCP Apps extension (io.modelcontextprotocol/ui) support Adds support for the MCP Apps protocol extension, which lets servers expose interactive HTML UIs as resources rendered by spec-conforming clients (e.g. Goose) in sandboxed iframes. - Generic extension plumbing: ServerExtensionInterface and Builder::enableExtension(), with extensions advertised under capabilities.extensions during the initialize handshake. ClientCapabilities/ServerCapabilities gain an extensions field. - Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker, UiToolMeta, ToolVisibility enum, UiResourceContentMeta, UiResourceCsp, and UiResourcePermissions (with empty-object presence markers per spec). - A worked weather example under examples/server/mcp-apps/ with a minimal spec-conforming view (ui/initialize handshake, postMessage JSONRPCMessage objects, and ResizeObserver-driven size reporting). - docs/extensions.md guide with pointers from index.md and examples.md to the ext-apps repository for the TypeScript SDK and richer view-side patterns. - Unit and inspector snapshot test coverage. Spec: https://github.com/modelcontextprotocol/ext-apps * Type UiToolMeta visibility as ToolVisibility enum Also fix UiResourceContentMeta::jsonSerialize() return phpdoc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review nitpicks for MCP Apps extension Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Apply PR review feedback for MCP Apps extension Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add regression tests and CHANGELOG entry for capabilities extensions - Pin the is_array() guards on extensions in ServerCapabilities and ClientCapabilities::fromArray() with explicit non-array payload tests. - Pin the isset()-rejects-null contract in UiResourcePermissions::fromArray(). - Surface the new extensions field on both capability classes in CHANGELOG. - Inline the ?: idiom in Builder::build() to match the surrounding style. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0347dc85b4
commit
d150772e24
29 changed files with 1632 additions and 3 deletions
|
|
@ -10,6 +10,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
||||||
* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias.
|
* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias.
|
||||||
* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`.
|
* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`.
|
||||||
* [BC Break] Bump default protocol version to `2025-11-25`
|
* [BC Break] Bump default protocol version to `2025-11-25`
|
||||||
|
* Add support for MCP Apps extension in schema and server
|
||||||
|
* Add `extensions` to `ServerCapabilities` and `ClientCapabilities` and `Builder::enableExtension()`
|
||||||
* Allow overriding the default name pattern for Discovery
|
* Allow overriding the default name pattern for Discovery
|
||||||
* Add configurable session garbage collection (`gcProbability`/`gcDivisor`)
|
* Add configurable session garbage collection (`gcProbability`/`gcDivisor`)
|
||||||
* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering.
|
* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering.
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@
|
||||||
"Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
|
"Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
|
||||||
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
|
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
|
||||||
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
|
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
|
||||||
|
"Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
|
||||||
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
|
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
|
||||||
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
|
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
|
||||||
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
|
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,18 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php
|
||||||
2. **confirm_action** - Simple boolean confirmation dialog
|
2. **confirm_action** - Simple boolean confirmation dialog
|
||||||
3. **collect_feedback** - Rating and comments form with optional fields
|
3. **collect_feedback** - Rating and comments form with optional fields
|
||||||
|
|
||||||
|
### MCP Apps
|
||||||
|
|
||||||
|
**File**: `examples/server/mcp-apps/`
|
||||||
|
|
||||||
|
A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://`
|
||||||
|
HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to
|
||||||
|
the `get_weather` tool. The bundled `weather-app.html` performs the
|
||||||
|
`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`,
|
||||||
|
and calls back into the server. See the
|
||||||
|
[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the
|
||||||
|
TypeScript SDK and richer view-side patterns.
|
||||||
|
|
||||||
## Client Examples
|
## Client Examples
|
||||||
|
|
||||||
### STDIO Discovery Calculator (Client)
|
### STDIO Discovery Calculator (Client)
|
||||||
|
|
|
||||||
110
docs/extensions.md
Normal file
110
docs/extensions.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Protocol Extensions
|
||||||
|
|
||||||
|
MCP protocol extensions advertise additional, optional capabilities during the initialize handshake.
|
||||||
|
A server opts in via `Builder::enableExtension()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
|
use Mcp\Server;
|
||||||
|
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('My Server', '1.0.0')
|
||||||
|
->enableExtension(new McpApps())
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass one or more `ServerExtensionInterface` instances; multiple extensions can
|
||||||
|
be enabled in a single call. Enabling the same extension twice throws a
|
||||||
|
`LogicException`.
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
## MCP Apps (`io.modelcontextprotocol/ui`)
|
||||||
|
|
||||||
|
The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as
|
||||||
|
resources. Clients that support it render them in sandboxed iframes and bridge
|
||||||
|
tool calls between the iframe (the *View*) and the server via the host.
|
||||||
|
|
||||||
|
A UI consists of two pieces wired together by `_meta.ui`:
|
||||||
|
|
||||||
|
1. **A resource** with URI scheme `ui://` and MIME type
|
||||||
|
`text/html;profile=mcp-app`, returning the HTML body.
|
||||||
|
2. **A tool** linked to that resource via `UiToolMeta`, so the client knows to
|
||||||
|
open the UI when the tool is invoked.
|
||||||
|
|
||||||
|
```php
|
||||||
|
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\UiResourcePermissions;
|
||||||
|
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||||
|
|
||||||
|
$server = Server::builder()
|
||||||
|
->enableExtension(new McpApps())
|
||||||
|
->addResource(
|
||||||
|
fn () => new TextResourceContents(
|
||||||
|
uri: 'ui://my-app',
|
||||||
|
mimeType: McpApps::MIME_TYPE,
|
||||||
|
text: file_get_contents(__DIR__.'/app.html'),
|
||||||
|
meta: ['ui' => new UiResourceContentMeta(
|
||||||
|
csp: new UiResourceCsp(connectDomains: ['https://api.example.com']),
|
||||||
|
permissions: new UiResourcePermissions(geolocation: true),
|
||||||
|
prefersBorder: true,
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
'ui://my-app',
|
||||||
|
mimeType: McpApps::MIME_TYPE,
|
||||||
|
meta: ['ui' => McpApps::resourceMarker()],
|
||||||
|
)
|
||||||
|
->addTool(
|
||||||
|
$myToolHandler,
|
||||||
|
'my_tool',
|
||||||
|
meta: ['ui' => new UiToolMeta(
|
||||||
|
resourceUri: 'ui://my-app',
|
||||||
|
visibility: [ToolVisibility::Model, ToolVisibility::App],
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the two distinct `_meta.ui` shapes: the resource *descriptor* (its
|
||||||
|
`resources/list` entry) carries only an empty marker — `McpApps::resourceMarker()` —
|
||||||
|
flagging it as an MCP App, while the resource *content* returned by `resources/read`
|
||||||
|
carries the structured `UiResourceContentMeta` with the actual CSP and permission
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
### Server-side DTOs
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `McpApps` | Extension marker; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME` constants. |
|
||||||
|
| `UiToolMeta` | Tool `_meta.ui` payload: `resourceUri` + `visibility`. |
|
||||||
|
| `ToolVisibility` | Enum: `Model`, `App`. |
|
||||||
|
| `UiResourceContentMeta` | Resource content `_meta.ui`: `csp`, `permissions`, `domain`, `prefersBorder`. |
|
||||||
|
| `UiResourceCsp` | CSP allow-lists: `connectDomains`, `resourceDomains`, `frameDomains`, `baseUriDomains`. |
|
||||||
|
| `UiResourcePermissions` | Sandbox permissions: `camera`, `microphone`, `geolocation`, `clipboardWrite`. |
|
||||||
|
|
||||||
|
### Writing the HTML view
|
||||||
|
|
||||||
|
The View and host exchange `JSONRPCMessage` **objects** (not JSON strings) via
|
||||||
|
`window.parent.postMessage`. Before the host forwards `tools/call`,
|
||||||
|
`tool-input`, or `tool-result`, the View must complete the spec-mandated
|
||||||
|
handshake:
|
||||||
|
|
||||||
|
1. View → Host: `ui/initialize` request
|
||||||
|
2. Host → View: response with `hostCapabilities`, `hostInfo`, `hostContext`
|
||||||
|
3. View → Host: `ui/notifications/initialized`
|
||||||
|
4. View → Host: `ui/notifications/size-changed` whenever the iframe wants to
|
||||||
|
resize
|
||||||
|
|
||||||
|
See the [`ext-apps` repository][ext-apps] for the full protocol, official
|
||||||
|
TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A
|
||||||
|
working minimal view is included in
|
||||||
|
[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html).
|
||||||
|
|
||||||
|
[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
|
||||||
|
|
@ -5,4 +5,5 @@
|
||||||
- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers.
|
- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers.
|
||||||
- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them.
|
- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them.
|
||||||
- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications.
|
- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications.
|
||||||
|
- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources).
|
||||||
- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more.
|
- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more.
|
||||||
|
|
|
||||||
67
examples/server/mcp-apps/WeatherApp.php
Normal file
67
examples/server/mcp-apps/WeatherApp.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?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\Example\Server\McpApps;
|
||||||
|
|
||||||
|
use Mcp\Schema\Content\TextResourceContents;
|
||||||
|
use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
|
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
||||||
|
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
||||||
|
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
||||||
|
|
||||||
|
final class WeatherApp
|
||||||
|
{
|
||||||
|
public function getWeatherApp(): TextResourceContents
|
||||||
|
{
|
||||||
|
$contentMeta = new UiResourceContentMeta(
|
||||||
|
csp: new UiResourceCsp(
|
||||||
|
connectDomains: ['https://api.weather.example.com'],
|
||||||
|
),
|
||||||
|
permissions: new UiResourcePermissions(
|
||||||
|
geolocation: true,
|
||||||
|
),
|
||||||
|
prefersBorder: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextResourceContents(
|
||||||
|
uri: 'ui://weather-app',
|
||||||
|
mimeType: McpApps::MIME_TYPE,
|
||||||
|
text: file_get_contents(__DIR__.'/weather-app.html'),
|
||||||
|
meta: ['ui' => $contentMeta],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeather(string $city): string
|
||||||
|
{
|
||||||
|
$weather = [
|
||||||
|
'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
|
||||||
|
'paris' => ['temp' => '18°C', 'condition' => 'Sunny', 'humidity' => '55%'],
|
||||||
|
'tokyo' => ['temp' => '22°C', 'condition' => 'Partly Cloudy', 'humidity' => '65%'],
|
||||||
|
'new york' => ['temp' => '12°C', 'condition' => 'Rainy', 'humidity' => '85%'],
|
||||||
|
'lagos' => ['temp' => '30°C', 'condition' => 'Sunny', 'humidity' => '82%'],
|
||||||
|
'stockholm' => ['temp' => '4°C', 'condition' => 'Cloudy', 'humidity' => '70%'],
|
||||||
|
'berlin' => ['temp' => '9°C', 'condition' => 'Partly Cloudy', 'humidity' => '68%'],
|
||||||
|
'sydney' => ['temp' => '26°C', 'condition' => 'Sunny', 'humidity' => '60%'],
|
||||||
|
'buenos aires' => ['temp' => '24°C', 'condition' => 'Rainy', 'humidity' => '80%'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$key = strtolower($city);
|
||||||
|
$data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
|
||||||
|
|
||||||
|
return \sprintf(
|
||||||
|
'Weather in %s: %s, %s, Humidity: %s',
|
||||||
|
$city,
|
||||||
|
$data['temp'],
|
||||||
|
$data['condition'],
|
||||||
|
$data['humidity'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
examples/server/mcp-apps/server.php
Normal file
51
examples/server/mcp-apps/server.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/bootstrap.php';
|
||||||
|
chdir(__DIR__);
|
||||||
|
|
||||||
|
use Mcp\Example\Server\McpApps\WeatherApp;
|
||||||
|
use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
|
use Mcp\Schema\Extension\Apps\ToolVisibility;
|
||||||
|
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||||
|
use Mcp\Server;
|
||||||
|
|
||||||
|
logger()->info('Starting MCP Apps Example Server...');
|
||||||
|
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('MCP Apps Weather Example', '1.0.0')
|
||||||
|
->setLogger(logger())
|
||||||
|
->enableExtension(new McpApps())
|
||||||
|
->addResource(
|
||||||
|
[WeatherApp::class, 'getWeatherApp'],
|
||||||
|
'ui://weather-app',
|
||||||
|
'weather-app',
|
||||||
|
description: 'Interactive weather dashboard',
|
||||||
|
mimeType: McpApps::MIME_TYPE,
|
||||||
|
meta: ['ui' => McpApps::resourceMarker()],
|
||||||
|
)
|
||||||
|
->addTool(
|
||||||
|
[WeatherApp::class, 'getWeather'],
|
||||||
|
'get_weather',
|
||||||
|
description: 'Get current weather for a city',
|
||||||
|
meta: ['ui' => new UiToolMeta(
|
||||||
|
resourceUri: 'ui://weather-app',
|
||||||
|
visibility: [ToolVisibility::Model, ToolVisibility::App],
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$result = $server->run(transport());
|
||||||
|
|
||||||
|
logger()->info('Server stopped gracefully.', ['result' => $result]);
|
||||||
|
|
||||||
|
shutdown($result);
|
||||||
230
examples/server/mcp-apps/weather-app.html
Normal file
230
examples/server/mcp-apps/weather-app.html
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Weather Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%);
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.18);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.32);
|
||||||
|
--text: #ffffff;
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
body[data-condition="sunny"] { --bg: linear-gradient(160deg, #f9a826 0%, #ffd166 100%); }
|
||||||
|
body[data-condition="partly-cloudy"]{ --bg: linear-gradient(160deg, #6da6e8 0%, #b8d8ff 100%); }
|
||||||
|
body[data-condition="cloudy"] { --bg: linear-gradient(160deg, #6c7a89 0%, #aab7b8 100%); }
|
||||||
|
body[data-condition="rainy"] { --bg: linear-gradient(160deg, #3a506b 0%, #5c7c9e 100%); }
|
||||||
|
body[data-condition="clear"] { --bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%); }
|
||||||
|
html, body { background: var(--bg); transition: background 0.6s ease; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
input::placeholder { color: var(--text-muted); }
|
||||||
|
input:focus { outline: 2px solid rgba(255, 255, 255, 0.5); }
|
||||||
|
button {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #2c3e50;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
button:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
}
|
||||||
|
.card.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.city-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.city { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
.icon { font-size: 3rem; line-height: 1; }
|
||||||
|
.temp { font-size: 4rem; font-weight: 700; letter-spacing: -2px; line-height: 1; }
|
||||||
|
.condition { color: var(--text-muted); margin-top: 0.25rem; }
|
||||||
|
.humidity {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.humidity-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.humidity-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-condition="clear">
|
||||||
|
<div class="form">
|
||||||
|
<input type="text" id="city" placeholder="Enter city name" value="London">
|
||||||
|
<button id="fetch-btn" onclick="fetchWeather()">Get Weather</button>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="result">
|
||||||
|
<div class="city-row">
|
||||||
|
<div>
|
||||||
|
<div class="city" id="city-name">—</div>
|
||||||
|
<div class="condition" id="condition">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="icon" id="icon">🌤️</div>
|
||||||
|
</div>
|
||||||
|
<div class="temp" id="temp">—</div>
|
||||||
|
<div class="humidity">
|
||||||
|
<span id="humidity-label">Humidity —</span>
|
||||||
|
<div class="humidity-bar"><div class="humidity-fill" id="humidity-fill" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Map condition strings to emojis and gradient themes.
|
||||||
|
const THEMES = {
|
||||||
|
sunny: { icon: '☀️', key: 'sunny' },
|
||||||
|
clear: { icon: '🌙', key: 'clear' },
|
||||||
|
'partly cloudy': { icon: '⛅', key: 'partly-cloudy' },
|
||||||
|
cloudy: { icon: '☁️', key: 'cloudy' },
|
||||||
|
rainy: { icon: '🌧️', key: 'rainy' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// MCP Apps protocol: postMessage with JSONRPCMessage objects between iframe (View) and host.
|
||||||
|
// Spec: https://github.com/modelcontextprotocol/ext-apps
|
||||||
|
let requestId = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
// Target origin is '*' because the iframe cannot know its parent's origin
|
||||||
|
// upfront; the host is responsible for validating event.origin on its side.
|
||||||
|
function post(msg) { window.parent.postMessage(msg, '*'); }
|
||||||
|
|
||||||
|
function sendRpc(method, params) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++requestId;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
post({ jsonrpc: '2.0', id, method, params });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNotification(method, params) {
|
||||||
|
post({ jsonrpc: '2.0', method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
// The MCP Apps spec exchanges JSONRPCMessage objects directly.
|
||||||
|
// Tolerate stringified payloads too, in case a host serializes them.
|
||||||
|
let msg = event.data;
|
||||||
|
if (typeof msg === 'string') {
|
||||||
|
try { msg = JSON.parse(msg); } catch { return; }
|
||||||
|
}
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
|
||||||
|
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = pending.get(msg.id);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
msg.error ? reject(msg.error) : resolve(msg.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.method === 'ui/notifications/tool-input') {
|
||||||
|
document.getElementById('city').value = msg.params?.arguments?.city || '';
|
||||||
|
} else if (msg.method === 'ui/notifications/tool-result') {
|
||||||
|
displayResult(msg.params);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spec-mandated handshake — without it, hosts (e.g. Goose) will not forward
|
||||||
|
// tool-input / tool-result notifications or tools/call requests.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await sendRpc('ui/initialize', {
|
||||||
|
protocolVersion: '2026-01-26',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'weather-app', version: '1.0.0' },
|
||||||
|
});
|
||||||
|
} catch { /* degrade gracefully on non-conforming hosts */ }
|
||||||
|
sendNotification('ui/notifications/initialized', {});
|
||||||
|
reportSize();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Tell the host how tall we want the iframe to be.
|
||||||
|
let lastW = 0, lastH = 0;
|
||||||
|
function reportSize() {
|
||||||
|
const w = Math.ceil(document.documentElement.scrollWidth);
|
||||||
|
const h = Math.ceil(document.documentElement.scrollHeight);
|
||||||
|
if (w === lastW && h === lastH) return;
|
||||||
|
lastW = w; lastH = h;
|
||||||
|
sendNotification('ui/notifications/size-changed', { width: w, height: h });
|
||||||
|
}
|
||||||
|
new ResizeObserver(reportSize).observe(document.body);
|
||||||
|
|
||||||
|
async function fetchWeather() {
|
||||||
|
const btn = document.getElementById('fetch-btn');
|
||||||
|
const city = document.getElementById('city').value.trim();
|
||||||
|
if (!city) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
const result = await sendRpc('tools/call', { name: 'get_weather', arguments: { city } });
|
||||||
|
if (result) displayResult(result);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Get Weather';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResult(result) {
|
||||||
|
const text = result.content?.[0]?.text ?? '';
|
||||||
|
// Parse "Weather in <city>: <temp>, <condition>, Humidity: <humidity>"
|
||||||
|
const match = text.match(/^Weather in (.+?): (.+?), (.+?), Humidity: (.+)$/);
|
||||||
|
if (!match) return;
|
||||||
|
const [, city, temp, condition, humidity] = match;
|
||||||
|
const theme = THEMES[condition.toLowerCase()] ?? { icon: '🌤️', key: 'clear' };
|
||||||
|
|
||||||
|
document.body.dataset.condition = theme.key;
|
||||||
|
document.getElementById('icon').textContent = theme.icon;
|
||||||
|
document.getElementById('city-name').textContent = city;
|
||||||
|
document.getElementById('condition').textContent = condition;
|
||||||
|
document.getElementById('temp').textContent = temp;
|
||||||
|
document.getElementById('humidity-label').textContent = `Humidity ${humidity}`;
|
||||||
|
document.getElementById('humidity-fill').style.width = humidity;
|
||||||
|
document.getElementById('result').classList.add('visible');
|
||||||
|
reportSize();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -20,7 +20,8 @@ namespace Mcp\Schema;
|
||||||
class ClientCapabilities implements \JsonSerializable
|
class ClientCapabilities implements \JsonSerializable
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $experimental
|
* @param array<string, mixed> $experimental
|
||||||
|
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?bool $roots = false,
|
public readonly ?bool $roots = false,
|
||||||
|
|
@ -28,6 +29,7 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
public readonly ?bool $sampling = null,
|
public readonly ?bool $sampling = null,
|
||||||
public readonly ?bool $elicitation = null,
|
public readonly ?bool $elicitation = null,
|
||||||
public readonly ?array $experimental = null,
|
public readonly ?array $experimental = null,
|
||||||
|
public readonly ?array $extensions = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
* sampling?: bool,
|
* sampling?: bool,
|
||||||
* elicitation?: bool,
|
* elicitation?: bool,
|
||||||
* experimental?: array<string, mixed>,
|
* experimental?: array<string, mixed>,
|
||||||
|
* extensions?: array<string, mixed>,
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
|
|
@ -68,7 +71,8 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
$rootsListChanged,
|
$rootsListChanged,
|
||||||
$sampling,
|
$sampling,
|
||||||
$elicitation,
|
$elicitation,
|
||||||
$data['experimental'] ?? null
|
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
|
||||||
|
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +82,7 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
* sampling?: object,
|
* sampling?: object,
|
||||||
* elicitation?: object,
|
* elicitation?: object,
|
||||||
* experimental?: object,
|
* experimental?: object,
|
||||||
|
* extensions?: object,
|
||||||
* }|\stdClass
|
* }|\stdClass
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize(): array|object
|
public function jsonSerialize(): array|object
|
||||||
|
|
@ -102,6 +107,10 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
$data['experimental'] = (object) $this->experimental;
|
$data['experimental'] = (object) $this->experimental;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->extensions) {
|
||||||
|
$data['extensions'] = (object) $this->extensions;
|
||||||
|
}
|
||||||
|
|
||||||
return $data ?: new \stdClass();
|
return $data ?: new \stdClass();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/Schema/Extension/Apps/McpApps.php
Normal file
58
src/Schema/Extension/Apps/McpApps.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
use Mcp\Schema\Extension\ServerExtensionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()}.
|
||||||
|
*
|
||||||
|
* @see https://github.com/modelcontextprotocol/ext-apps
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class McpApps implements ServerExtensionInterface
|
||||||
|
{
|
||||||
|
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
|
||||||
|
public const MIME_TYPE = 'text/html;profile=mcp-app';
|
||||||
|
public const URI_SCHEME = 'ui';
|
||||||
|
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return self::EXTENSION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{mimeTypes: string[]}
|
||||||
|
*/
|
||||||
|
public function getCapabilities(): array
|
||||||
|
{
|
||||||
|
return ['mimeTypes' => [self::MIME_TYPE]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marker value for the `_meta.ui` field on a UI resource *descriptor*
|
||||||
|
* (its `resources/list` entry), flagging the resource as an MCP App.
|
||||||
|
*
|
||||||
|
* The structured CSP/permissions metadata instead belongs on the resource
|
||||||
|
* *content* (the `resources/read` payload) via {@see UiResourceContentMeta}.
|
||||||
|
*/
|
||||||
|
public static function resourceMarker(): \stdClass
|
||||||
|
{
|
||||||
|
return new \stdClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Schema/Extension/Apps/ToolVisibility.php
Normal file
26
src/Schema/Extension/Apps/ToolVisibility.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls who can see and invoke a tool linked to a UI resource in an MCP App.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
enum ToolVisibility: string
|
||||||
|
{
|
||||||
|
/** Visible to and callable by the LLM agent. */
|
||||||
|
case Model = 'model';
|
||||||
|
|
||||||
|
/** Callable by the MCP App (HTML view) only, hidden from the model's tools/list. */
|
||||||
|
case App = 'app';
|
||||||
|
}
|
||||||
81
src/Schema/Extension/Apps/UiResourceContentMeta.php
Normal file
81
src/Schema/Extension/Apps/UiResourceContentMeta.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for the _meta.ui field on resource content in a resources/read response.
|
||||||
|
*
|
||||||
|
* This describes the security and display requirements for the rendered HTML app.
|
||||||
|
*
|
||||||
|
* @phpstan-import-type UiResourceCspData from UiResourceCsp
|
||||||
|
* @phpstan-import-type UiResourcePermissionsData from UiResourcePermissions
|
||||||
|
*
|
||||||
|
* @phpstan-type UiResourceContentMetaData array{
|
||||||
|
* csp?: UiResourceCspData,
|
||||||
|
* permissions?: UiResourcePermissionsData,
|
||||||
|
* domain?: string,
|
||||||
|
* prefersBorder?: bool
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class UiResourceContentMeta implements \JsonSerializable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?UiResourceCsp $csp = null,
|
||||||
|
public readonly ?UiResourcePermissions $permissions = null,
|
||||||
|
public readonly ?string $domain = null,
|
||||||
|
public readonly ?bool $prefersBorder = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UiResourceContentMetaData $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null,
|
||||||
|
permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null,
|
||||||
|
domain: $data['domain'] ?? null,
|
||||||
|
prefersBorder: $data['prefersBorder'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* csp?: UiResourceCsp,
|
||||||
|
* permissions?: UiResourcePermissions,
|
||||||
|
* domain?: string,
|
||||||
|
* prefersBorder?: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if (null !== $this->csp) {
|
||||||
|
$data['csp'] = $this->csp;
|
||||||
|
}
|
||||||
|
if (null !== $this->permissions) {
|
||||||
|
$data['permissions'] = $this->permissions;
|
||||||
|
}
|
||||||
|
if (null !== $this->domain) {
|
||||||
|
$data['domain'] = $this->domain;
|
||||||
|
}
|
||||||
|
if (null !== $this->prefersBorder) {
|
||||||
|
$data['prefersBorder'] = $this->prefersBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/Schema/Extension/Apps/UiResourceCsp.php
Normal file
82
src/Schema/Extension/Apps/UiResourceCsp.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content Security Policy configuration for MCP App resources.
|
||||||
|
*
|
||||||
|
* Controls which external domains the rendered HTML app can access.
|
||||||
|
* If omitted entirely, a restrictive default policy is applied by the host.
|
||||||
|
*
|
||||||
|
* @phpstan-type UiResourceCspData array{
|
||||||
|
* connectDomains?: string[],
|
||||||
|
* resourceDomains?: string[],
|
||||||
|
* frameDomains?: string[],
|
||||||
|
* baseUriDomains?: string[]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class UiResourceCsp implements \JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ?string[] $connectDomains domains allowed for network requests (fetch, XHR, WebSocket)
|
||||||
|
* @param ?string[] $resourceDomains domains allowed for static resources (images, scripts, styles)
|
||||||
|
* @param ?string[] $frameDomains domains allowed for nested iframes
|
||||||
|
* @param ?string[] $baseUriDomains domains allowed for base URI origins
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?array $connectDomains = null,
|
||||||
|
public readonly ?array $resourceDomains = null,
|
||||||
|
public readonly ?array $frameDomains = null,
|
||||||
|
public readonly ?array $baseUriDomains = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UiResourceCspData $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
connectDomains: $data['connectDomains'] ?? null,
|
||||||
|
resourceDomains: $data['resourceDomains'] ?? null,
|
||||||
|
frameDomains: $data['frameDomains'] ?? null,
|
||||||
|
baseUriDomains: $data['baseUriDomains'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return UiResourceCspData
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
// The MCP Apps spec (2026-01-26) defines "empty or omitted" identically
|
||||||
|
// for every CSP allow-list, so empty arrays are dropped, not emitted as `[]`.
|
||||||
|
if ($this->connectDomains) {
|
||||||
|
$data['connectDomains'] = $this->connectDomains;
|
||||||
|
}
|
||||||
|
if ($this->resourceDomains) {
|
||||||
|
$data['resourceDomains'] = $this->resourceDomains;
|
||||||
|
}
|
||||||
|
if ($this->frameDomains) {
|
||||||
|
$data['frameDomains'] = $this->frameDomains;
|
||||||
|
}
|
||||||
|
if ($this->baseUriDomains) {
|
||||||
|
$data['baseUriDomains'] = $this->baseUriDomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Schema/Extension/Apps/UiResourcePermissions.php
Normal file
76
src/Schema/Extension/Apps/UiResourcePermissions.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sandbox permissions that an MCP App resource requests from the host.
|
||||||
|
*
|
||||||
|
* Per spec each requested permission is a presence marker (serialized as `{}`),
|
||||||
|
* so the wire shape is e.g. `{"geolocation": {}}`, not `{"geolocation": true}`.
|
||||||
|
*
|
||||||
|
* @phpstan-type UiResourcePermissionsData array{
|
||||||
|
* camera?: \stdClass|array<string, mixed>,
|
||||||
|
* microphone?: \stdClass|array<string, mixed>,
|
||||||
|
* geolocation?: \stdClass|array<string, mixed>,
|
||||||
|
* clipboardWrite?: \stdClass|array<string, mixed>
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class UiResourcePermissions implements \JsonSerializable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $camera = false,
|
||||||
|
public readonly bool $microphone = false,
|
||||||
|
public readonly bool $geolocation = false,
|
||||||
|
public readonly bool $clipboardWrite = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UiResourcePermissionsData $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
// A permission is requested when its key is present with the spec's `{}`
|
||||||
|
// marker; isset() accepts that (array/object forms) and rejects a stray null.
|
||||||
|
return new self(
|
||||||
|
camera: isset($data['camera']),
|
||||||
|
microphone: isset($data['microphone']),
|
||||||
|
geolocation: isset($data['geolocation']),
|
||||||
|
clipboardWrite: isset($data['clipboardWrite']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \stdClass>
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if ($this->camera) {
|
||||||
|
$data['camera'] = new \stdClass();
|
||||||
|
}
|
||||||
|
if ($this->microphone) {
|
||||||
|
$data['microphone'] = new \stdClass();
|
||||||
|
}
|
||||||
|
if ($this->geolocation) {
|
||||||
|
$data['geolocation'] = new \stdClass();
|
||||||
|
}
|
||||||
|
if ($this->clipboardWrite) {
|
||||||
|
$data['clipboardWrite'] = new \stdClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Schema/Extension/Apps/UiToolMeta.php
Normal file
64
src/Schema/Extension/Apps/UiToolMeta.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?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\Apps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for the _meta.ui field on a Tool, linking it to a UI resource.
|
||||||
|
*
|
||||||
|
* @phpstan-type UiToolMetaData array{
|
||||||
|
* resourceUri?: string,
|
||||||
|
* visibility?: string[]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class UiToolMeta implements \JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ?string $resourceUri the ui:// URI of the linked UI resource
|
||||||
|
* @param ?list<ToolVisibility> $visibility who can see/call this tool; when omitted the host
|
||||||
|
* defaults to both {@see ToolVisibility::Model} and {@see ToolVisibility::App}
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?string $resourceUri = null,
|
||||||
|
public readonly ?array $visibility = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UiToolMetaData $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
resourceUri: $data['resourceUri'] ?? null,
|
||||||
|
visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return UiToolMetaData
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if (null !== $this->resourceUri) {
|
||||||
|
$data['resourceUri'] = $this->resourceUri;
|
||||||
|
}
|
||||||
|
if (null !== $this->visibility) {
|
||||||
|
$data['visibility'] = array_map(static fn (ToolVisibility $v): string => $v->value, $this->visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Schema/Extension/ServerExtensionInterface.php
Normal file
40
src/Schema/Extension/ServerExtensionInterface.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[<id>]` in the initialize
|
||||||
|
* response.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
interface ServerExtensionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 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>]` in the initialize response, so every value
|
||||||
|
* must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getCapabilities(): array;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
* @param ?bool $logging server emits structured log messages
|
* @param ?bool $logging server emits structured log messages
|
||||||
* @param ?bool $completions Server supports argument autocompletion
|
* @param ?bool $completions Server supports argument autocompletion
|
||||||
* @param ?array<string, mixed> $experimental experimental, non-standard features that the server supports
|
* @param ?array<string, mixed> $experimental experimental, non-standard features that the server supports
|
||||||
|
* @param ?array<string, mixed> $extensions protocol extensions the server supports (e.g. io.modelcontextprotocol/ui)
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?bool $tools = true,
|
public readonly ?bool $tools = true,
|
||||||
|
|
@ -42,6 +43,7 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
public readonly ?bool $logging = false,
|
public readonly ?bool $logging = false,
|
||||||
public readonly ?bool $completions = false,
|
public readonly ?bool $completions = false,
|
||||||
public readonly ?array $experimental = null,
|
public readonly ?array $experimental = null,
|
||||||
|
public readonly ?array $extensions = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
* resources?: array{listChanged?: bool, subscribe?: bool}|object,
|
* resources?: array{listChanged?: bool, subscribe?: bool}|object,
|
||||||
* tools?: object|array{listChanged?: bool},
|
* tools?: object|array{listChanged?: bool},
|
||||||
* experimental?: array<string, mixed>,
|
* experimental?: array<string, mixed>,
|
||||||
|
* extensions?: array<string, mixed>,
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
|
|
@ -106,7 +109,32 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
promptsListChanged: $promptsListChanged,
|
promptsListChanged: $promptsListChanged,
|
||||||
logging: $loggingEnabled,
|
logging: $loggingEnabled,
|
||||||
completions: $completionsEnabled,
|
completions: $completionsEnabled,
|
||||||
experimental: $data['experimental'] ?? null,
|
experimental: \is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
|
||||||
|
extensions: \is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the given protocol extensions merged into the existing ones.
|
||||||
|
*
|
||||||
|
* Entries in $extensions override existing ones sharing the same id.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string, mixed>> $extensions
|
||||||
|
*/
|
||||||
|
public function withExtensions(array $extensions): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->tools,
|
||||||
|
$this->toolsListChanged,
|
||||||
|
$this->resources,
|
||||||
|
$this->resourcesSubscribe,
|
||||||
|
$this->resourcesListChanged,
|
||||||
|
$this->prompts,
|
||||||
|
$this->promptsListChanged,
|
||||||
|
$this->logging,
|
||||||
|
$this->completions,
|
||||||
|
$this->experimental,
|
||||||
|
[...$this->extensions ?? [], ...$extensions],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +146,7 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
* resources?: object,
|
* resources?: object,
|
||||||
* tools?: object,
|
* tools?: object,
|
||||||
* experimental?: object,
|
* experimental?: object,
|
||||||
|
* extensions?: object,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
|
|
@ -159,6 +188,10 @@ class ServerCapabilities implements \JsonSerializable
|
||||||
$data['experimental'] = (object) $this->experimental;
|
$data['experimental'] = (object) $this->experimental;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->extensions) {
|
||||||
|
$data['extensions'] = (object) $this->extensions;
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,11 @@ use Mcp\Capability\Registry\ReferenceHandler;
|
||||||
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
||||||
use Mcp\Capability\RegistryInterface;
|
use Mcp\Capability\RegistryInterface;
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Exception\LogicException;
|
||||||
use Mcp\JsonRpc\MessageFactory;
|
use Mcp\JsonRpc\MessageFactory;
|
||||||
use Mcp\Schema\Annotations;
|
use Mcp\Schema\Annotations;
|
||||||
use Mcp\Schema\Enum\ProtocolVersion;
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\Extension\ServerExtensionInterface;
|
||||||
use Mcp\Schema\Icon;
|
use Mcp\Schema\Icon;
|
||||||
use Mcp\Schema\Implementation;
|
use Mcp\Schema\Implementation;
|
||||||
use Mcp\Schema\Prompt;
|
use Mcp\Schema\Prompt;
|
||||||
|
|
@ -207,6 +209,11 @@ final class Builder
|
||||||
|
|
||||||
private ?ServerCapabilities $serverCapabilities = null;
|
private ?ServerCapabilities $serverCapabilities = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private array $extensions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var LoaderInterface[]
|
* @var LoaderInterface[]
|
||||||
*/
|
*/
|
||||||
|
|
@ -263,6 +270,27 @@ final class Builder
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable one or more MCP protocol extensions, announced to clients under
|
||||||
|
* `capabilities.extensions` during the initialize handshake.
|
||||||
|
*
|
||||||
|
* @throws LogicException if the same extension is enabled more than once
|
||||||
|
*/
|
||||||
|
public function enableExtension(ServerExtensionInterface ...$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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a single custom method handler.
|
* Register a single custom method handler.
|
||||||
*
|
*
|
||||||
|
|
@ -661,8 +689,15 @@ final class Builder
|
||||||
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
|
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
|
||||||
logging: true,
|
logging: true,
|
||||||
completions: true,
|
completions: true,
|
||||||
|
extensions: $this->extensions ?: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extensions enabled via enableExtension() are folded into caller-supplied
|
||||||
|
// capabilities too, so setCapabilities() does not silently drop them.
|
||||||
|
if (null !== $this->serverCapabilities && [] !== $this->extensions) {
|
||||||
|
$capabilities = $capabilities->withExtensions($this->extensions);
|
||||||
|
}
|
||||||
|
|
||||||
$serverInfo = $this->serverInfo ?? new Implementation();
|
$serverInfo = $this->serverInfo ?? new Implementation();
|
||||||
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
|
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
|
||||||
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);
|
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);
|
||||||
|
|
|
||||||
52
tests/Inspector/Stdio/StdioMcpAppsTest.php
Normal file
52
tests/Inspector/Stdio/StdioMcpAppsTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?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 Inspector;
|
||||||
|
|
||||||
|
use Mcp\Tests\Inspector\Stdio\StdioInspectorSnapshotTestCase;
|
||||||
|
|
||||||
|
final class StdioMcpAppsTest extends StdioInspectorSnapshotTestCase
|
||||||
|
{
|
||||||
|
public static function provideMethods(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::provideMethods(),
|
||||||
|
'Read Weather UI Resource' => [
|
||||||
|
'method' => 'resources/read',
|
||||||
|
'options' => [
|
||||||
|
'uri' => 'ui://weather-app',
|
||||||
|
],
|
||||||
|
'testName' => 'read_weather_ui',
|
||||||
|
],
|
||||||
|
'Get Weather Tool Call (London)' => [
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'options' => [
|
||||||
|
'toolName' => 'get_weather',
|
||||||
|
'toolArgs' => ['city' => 'London'],
|
||||||
|
],
|
||||||
|
'testName' => 'get_weather_london',
|
||||||
|
],
|
||||||
|
'Get Weather Tool Call (Tokyo)' => [
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'options' => [
|
||||||
|
'toolName' => 'get_weather',
|
||||||
|
'toolArgs' => ['city' => 'Tokyo'],
|
||||||
|
],
|
||||||
|
'testName' => 'get_weather_tokyo',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getServerScript(): string
|
||||||
|
{
|
||||||
|
return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"prompts": []
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue