Skip to content

Commit efcabd7

Browse files
chr-hertelclaude
andcommitted
feat: add MCP Apps extension (io.modelcontextprotocol/ui) support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 73e4cc2 commit efcabd7

17 files changed

Lines changed: 1167 additions & 3 deletions

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
7777
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
7878
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
79+
"Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
7980
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
8081
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
8182
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
@@ -89,4 +90,4 @@
8990
},
9091
"sort-packages": true
9192
}
92-
}
93+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Example\Server\McpApps;
13+
14+
use Mcp\Schema\Content\TextResourceContents;
15+
use Mcp\Schema\Extension\Apps\McpApps;
16+
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
17+
use Mcp\Schema\Extension\Apps\UiResourceCsp;
18+
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
19+
20+
final class WeatherApp
21+
{
22+
public function getWeatherApp(): TextResourceContents
23+
{
24+
$contentMeta = new UiResourceContentMeta(
25+
csp: new UiResourceCsp(
26+
connectDomains: ['https://api.weather.example.com'],
27+
),
28+
permissions: new UiResourcePermissions(
29+
geolocation: true,
30+
),
31+
prefersBorder: true,
32+
);
33+
34+
return new TextResourceContents(
35+
uri: 'ui://weather-app',
36+
mimeType: McpApps::MIME_TYPE,
37+
text: file_get_contents(__DIR__.'/weather-app.html'),
38+
meta: ['ui' => $contentMeta],
39+
);
40+
}
41+
42+
public function getWeather(string $city): string
43+
{
44+
$weather = [
45+
'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
46+
'paris' => ['temp' => '18°C', 'condition' => 'Sunny', 'humidity' => '55%'],
47+
'tokyo' => ['temp' => '22°C', 'condition' => 'Partly Cloudy', 'humidity' => '65%'],
48+
'new york' => ['temp' => '12°C', 'condition' => 'Rainy', 'humidity' => '85%'],
49+
];
50+
51+
$key = strtolower($city);
52+
$data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
53+
54+
return \sprintf(
55+
'Weather in %s: %s, %s, Humidity: %s',
56+
$city,
57+
$data['temp'],
58+
$data['condition'],
59+
$data['humidity'],
60+
);
61+
}
62+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/*
5+
* This file is part of the official PHP MCP SDK.
6+
*
7+
* A collaboration between Symfony and the PHP Foundation.
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
require_once dirname(__DIR__).'/bootstrap.php';
14+
chdir(__DIR__);
15+
16+
use Mcp\Example\Server\McpApps\WeatherApp;
17+
use Mcp\Schema\Enum\ToolVisibility;
18+
use Mcp\Schema\Extension\Apps\McpApps;
19+
use Mcp\Schema\Extension\Apps\UiToolMeta;
20+
use Mcp\Server;
21+
22+
logger()->info('Starting MCP Apps Example Server...');
23+
24+
$server = Server::builder()
25+
->setServerInfo('MCP Apps Weather Example', '1.0.0')
26+
->setLogger(logger())
27+
->enableExtension(McpApps::class)
28+
->addResource(
29+
[WeatherApp::class, 'getWeatherApp'],
30+
'ui://weather-app',
31+
'weather-app',
32+
description: 'Interactive weather dashboard',
33+
mimeType: McpApps::MIME_TYPE,
34+
meta: ['ui' => []],
35+
)
36+
->addTool(
37+
[WeatherApp::class, 'getWeather'],
38+
'get_weather',
39+
description: 'Get current weather for a city',
40+
meta: ['ui' => new UiToolMeta(
41+
resourceUri: 'ui://weather-app',
42+
visibility: [ToolVisibility::Model->value, ToolVisibility::App->value],
43+
)],
44+
)
45+
->build();
46+
47+
$result = $server->run(transport());
48+
49+
logger()->info('Server stopped gracefully.', ['result' => $result]);
50+
51+
shutdown($result);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Weather Dashboard</title>
7+
<style>
8+
* { margin: 0; padding: 0; box-sizing: border-box; }
9+
body {
10+
font-family: var(--font-sans, system-ui, sans-serif);
11+
background: var(--color-background-primary, #ffffff);
12+
color: var(--color-text-primary, #1a1a1a);
13+
padding: 1rem;
14+
}
15+
.card {
16+
border: 1px solid var(--color-border-primary, #e0e0e0);
17+
border-radius: var(--border-radius-md, 8px);
18+
padding: 1rem;
19+
margin-bottom: 1rem;
20+
}
21+
h1 { font-size: var(--font-heading-md-size, 1.25rem); margin-bottom: 0.5rem; }
22+
.weather-data { font-size: var(--font-text-lg-size, 1.125rem); }
23+
button {
24+
background: var(--color-background-info, #0066cc);
25+
color: var(--color-text-inverse, #ffffff);
26+
border: none;
27+
border-radius: var(--border-radius-sm, 4px);
28+
padding: 0.5rem 1rem;
29+
cursor: pointer;
30+
font-size: var(--font-text-md-size, 1rem);
31+
}
32+
input {
33+
border: 1px solid var(--color-border-primary, #e0e0e0);
34+
border-radius: var(--border-radius-sm, 4px);
35+
padding: 0.5rem;
36+
font-size: var(--font-text-md-size, 1rem);
37+
margin-right: 0.5rem;
38+
}
39+
</style>
40+
</head>
41+
<body>
42+
<div class="card">
43+
<h1>Weather Dashboard</h1>
44+
<div>
45+
<input type="text" id="city" placeholder="Enter city name" value="London">
46+
<button onclick="fetchWeather()">Get Weather</button>
47+
</div>
48+
</div>
49+
<div class="card" id="result" style="display:none">
50+
<div class="weather-data" id="weather-data"></div>
51+
</div>
52+
<script>
53+
// MCP Apps communicate with the host via window.parent.postMessage
54+
// using JSON-RPC 2.0 messages.
55+
let requestId = 0;
56+
const pending = new Map();
57+
58+
window.addEventListener('message', (event) => {
59+
try {
60+
const msg = JSON.parse(event.data);
61+
if (msg.id && pending.has(msg.id)) {
62+
pending.get(msg.id)(msg);
63+
pending.delete(msg.id);
64+
}
65+
// Handle tool input notification
66+
if (msg.method === 'ui/notifications/tool-input') {
67+
document.getElementById('city').value = msg.params.arguments.city || '';
68+
}
69+
// Handle tool result notification
70+
if (msg.method === 'ui/notifications/tool-result') {
71+
displayResult(msg.params);
72+
}
73+
} catch (e) { /* ignore non-JSON messages */ }
74+
});
75+
76+
function sendRpc(method, params) {
77+
return new Promise((resolve) => {
78+
const id = ++requestId;
79+
pending.set(id, resolve);
80+
window.parent.postMessage(JSON.stringify({
81+
jsonrpc: '2.0', id, method, params
82+
}), '*');
83+
});
84+
}
85+
86+
async function fetchWeather() {
87+
const city = document.getElementById('city').value;
88+
const response = await sendRpc('tools/call', {
89+
name: 'get_weather',
90+
arguments: { city }
91+
});
92+
if (response.result) {
93+
displayResult(response.result);
94+
}
95+
}
96+
97+
function displayResult(result) {
98+
const el = document.getElementById('result');
99+
const data = document.getElementById('weather-data');
100+
if (result.content && result.content[0]) {
101+
data.textContent = result.content[0].text;
102+
}
103+
el.style.display = 'block';
104+
}
105+
</script>
106+
</body>
107+
</html>

src/Schema/ClientCapabilities.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@
2020
class ClientCapabilities implements \JsonSerializable
2121
{
2222
/**
23-
* @param array<string, mixed> $experimental
23+
* @param array<string, mixed> $experimental
24+
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
2425
*/
2526
public function __construct(
2627
public readonly ?bool $roots = false,
2728
public readonly ?bool $rootsListChanged = null,
2829
public readonly ?bool $sampling = null,
2930
public readonly ?bool $elicitation = null,
3031
public readonly ?array $experimental = null,
32+
public readonly ?array $extensions = null,
3133
) {
3234
}
3335

@@ -39,6 +41,7 @@ public function __construct(
3941
* sampling?: bool,
4042
* elicitation?: bool,
4143
* experimental?: array<string, mixed>,
44+
* extensions?: array<string, mixed>,
4245
* } $data
4346
*/
4447
public static function fromArray(array $data): self
@@ -68,7 +71,8 @@ public static function fromArray(array $data): self
6871
$rootsListChanged,
6972
$sampling,
7073
$elicitation,
71-
$data['experimental'] ?? null
74+
$data['experimental'] ?? null,
75+
$data['extensions'] ?? null,
7276
);
7377
}
7478

@@ -78,6 +82,7 @@ public static function fromArray(array $data): self
7882
* sampling?: object,
7983
* elicitation?: object,
8084
* experimental?: object,
85+
* extensions?: object,
8186
* }
8287
*/
8388
public function jsonSerialize(): array
@@ -102,6 +107,10 @@ public function jsonSerialize(): array
102107
$data['experimental'] = (object) $this->experimental;
103108
}
104109

110+
if ($this->extensions) {
111+
$data['extensions'] = (object) $this->extensions;
112+
}
113+
105114
return $data;
106115
}
107116
}

src/Schema/Enum/ToolVisibility.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Schema\Enum;
13+
14+
/**
15+
* Tool visibility values for MCP Apps.
16+
*
17+
* Controls who can see and invoke a tool linked to a UI resource.
18+
*/
19+
enum ToolVisibility: string
20+
{
21+
/** Visible to and callable by the LLM agent. */
22+
case Model = 'model';
23+
24+
/** Callable by the MCP App (HTML view) only, hidden from the model's tools/list. */
25+
case App = 'app';
26+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Schema\Extension\Apps;
13+
14+
use Mcp\Schema\Extension\ServerExtensionInterface;
15+
use Mcp\Schema\Resource;
16+
17+
/**
18+
* The MCP Apps extension (io.modelcontextprotocol/ui).
19+
*
20+
* MCP Apps allows servers to expose interactive HTML UI applications as resources.
21+
* Clients that support the extension render these in sandboxed iframes.
22+
*
23+
* Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}.
24+
*
25+
* @see https://github.com/modelcontextprotocol/ext-apps
26+
*/
27+
final class McpApps implements ServerExtensionInterface
28+
{
29+
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
30+
public const MIME_TYPE = 'text/html;profile=mcp-app';
31+
public const URI_SCHEME = 'ui';
32+
33+
public function getId(): string
34+
{
35+
return self::EXTENSION_ID;
36+
}
37+
38+
/**
39+
* @return array{mimeTypes: string[]}
40+
*/
41+
public function getCapabilities(): array
42+
{
43+
return ['mimeTypes' => [self::MIME_TYPE]];
44+
}
45+
46+
/**
47+
* Checks whether a Resource is a UI resource based on its URI scheme and MIME type.
48+
*/
49+
public static function isUiResource(Resource $resource): bool
50+
{
51+
return str_starts_with($resource->uri, self::URI_SCHEME.'://')
52+
&& self::MIME_TYPE === $resource->mimeType;
53+
}
54+
}

0 commit comments

Comments
 (0)