Skip to content

Commit 4d0eb19

Browse files
committed
feat: add MCP Apps extension (io.modelcontextprotocol/ui) support
1 parent 73e4cc2 commit 4d0eb19

25 files changed

Lines changed: 1402 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\Extension\Apps\McpApps;
18+
use Mcp\Schema\Extension\Apps\ToolVisibility;
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' => new stdClass()],
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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
:root {
10+
--bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%);
11+
--card-bg: rgba(255, 255, 255, 0.18);
12+
--card-border: rgba(255, 255, 255, 0.32);
13+
--text: #ffffff;
14+
--text-muted: rgba(255, 255, 255, 0.78);
15+
}
16+
body[data-condition="sunny"] { --bg: linear-gradient(160deg, #f9a826 0%, #ffd166 100%); }
17+
body[data-condition="partly-cloudy"]{ --bg: linear-gradient(160deg, #6da6e8 0%, #b8d8ff 100%); }
18+
body[data-condition="cloudy"] { --bg: linear-gradient(160deg, #6c7a89 0%, #aab7b8 100%); }
19+
body[data-condition="rainy"] { --bg: linear-gradient(160deg, #3a506b 0%, #5c7c9e 100%); }
20+
body[data-condition="clear"] { --bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%); }
21+
body {
22+
font-family: system-ui, -apple-system, sans-serif;
23+
background: var(--bg);
24+
color: var(--text);
25+
padding: 1.5rem;
26+
min-height: 100vh;
27+
transition: background 0.6s ease;
28+
}
29+
.form {
30+
display: flex;
31+
gap: 0.5rem;
32+
margin-bottom: 1.5rem;
33+
}
34+
input {
35+
flex: 1;
36+
background: var(--card-bg);
37+
border: 1px solid var(--card-border);
38+
border-radius: 10px;
39+
padding: 0.75rem 1rem;
40+
font-size: 1rem;
41+
color: var(--text);
42+
backdrop-filter: blur(8px);
43+
}
44+
input::placeholder { color: var(--text-muted); }
45+
input:focus { outline: 2px solid rgba(255, 255, 255, 0.5); }
46+
button {
47+
background: rgba(255, 255, 255, 0.95);
48+
color: #2c3e50;
49+
border: none;
50+
border-radius: 10px;
51+
padding: 0.75rem 1.25rem;
52+
font-size: 1rem;
53+
font-weight: 600;
54+
cursor: pointer;
55+
transition: transform 0.15s ease, opacity 0.15s ease;
56+
}
57+
button:hover:not(:disabled) { transform: translateY(-1px); }
58+
button:disabled { opacity: 0.6; cursor: wait; }
59+
.card {
60+
background: var(--card-bg);
61+
border: 1px solid var(--card-border);
62+
border-radius: 18px;
63+
padding: 1.5rem;
64+
backdrop-filter: blur(12px);
65+
opacity: 0;
66+
transform: translateY(8px);
67+
transition: opacity 0.4s ease, transform 0.4s ease;
68+
}
69+
.card.visible { opacity: 1; transform: translateY(0); }
70+
.city-row {
71+
display: flex;
72+
align-items: center;
73+
justify-content: space-between;
74+
margin-bottom: 1rem;
75+
}
76+
.city { font-size: 1.25rem; font-weight: 600; }
77+
.icon { font-size: 3rem; line-height: 1; }
78+
.temp { font-size: 4rem; font-weight: 700; letter-spacing: -2px; line-height: 1; }
79+
.condition { color: var(--text-muted); margin-top: 0.25rem; }
80+
.humidity {
81+
margin-top: 1.25rem;
82+
font-size: 0.875rem;
83+
color: var(--text-muted);
84+
}
85+
.humidity-bar {
86+
height: 6px;
87+
background: rgba(255, 255, 255, 0.18);
88+
border-radius: 3px;
89+
margin-top: 0.4rem;
90+
overflow: hidden;
91+
}
92+
.humidity-fill {
93+
height: 100%;
94+
background: rgba(255, 255, 255, 0.85);
95+
border-radius: 3px;
96+
transition: width 0.6s ease;
97+
}
98+
</style>
99+
</head>
100+
<body data-condition="clear">
101+
<div class="form">
102+
<input type="text" id="city" placeholder="Enter city name" value="London">
103+
<button id="fetch-btn" onclick="fetchWeather()">Get Weather</button>
104+
</div>
105+
<div class="card" id="result">
106+
<div class="city-row">
107+
<div>
108+
<div class="city" id="city-name"></div>
109+
<div class="condition" id="condition"></div>
110+
</div>
111+
<div class="icon" id="icon">🌤️</div>
112+
</div>
113+
<div class="temp" id="temp"></div>
114+
<div class="humidity">
115+
<span id="humidity-label">Humidity —</span>
116+
<div class="humidity-bar"><div class="humidity-fill" id="humidity-fill" style="width:0%"></div></div>
117+
</div>
118+
</div>
119+
<script>
120+
// Map condition strings to emojis and gradient themes.
121+
const THEMES = {
122+
sunny: { icon: '☀️', key: 'sunny' },
123+
clear: { icon: '🌙', key: 'clear' },
124+
'partly cloudy': { icon: '⛅', key: 'partly-cloudy' },
125+
cloudy: { icon: '☁️', key: 'cloudy' },
126+
rainy: { icon: '🌧️', key: 'rainy' },
127+
};
128+
129+
// MCP Apps communicate with the host via window.parent.postMessage using JSON-RPC 2.0.
130+
let requestId = 0;
131+
const pending = new Map();
132+
133+
window.addEventListener('message', (event) => {
134+
try {
135+
const msg = JSON.parse(event.data);
136+
if (msg.id && pending.has(msg.id)) {
137+
pending.get(msg.id)(msg);
138+
pending.delete(msg.id);
139+
}
140+
if (msg.method === 'ui/notifications/tool-input') {
141+
document.getElementById('city').value = msg.params.arguments.city || '';
142+
}
143+
if (msg.method === 'ui/notifications/tool-result') {
144+
displayResult(msg.params);
145+
}
146+
} catch (e) { /* ignore non-JSON messages */ }
147+
});
148+
149+
function sendRpc(method, params) {
150+
return new Promise((resolve) => {
151+
const id = ++requestId;
152+
pending.set(id, resolve);
153+
window.parent.postMessage(JSON.stringify({ jsonrpc: '2.0', id, method, params }), '*');
154+
});
155+
}
156+
157+
async function fetchWeather() {
158+
const btn = document.getElementById('fetch-btn');
159+
const city = document.getElementById('city').value.trim();
160+
if (!city) return;
161+
btn.disabled = true;
162+
btn.textContent = 'Loading…';
163+
try {
164+
const response = await sendRpc('tools/call', { name: 'get_weather', arguments: { city } });
165+
if (response.result) displayResult(response.result);
166+
} finally {
167+
btn.disabled = false;
168+
btn.textContent = 'Get Weather';
169+
}
170+
}
171+
172+
function displayResult(result) {
173+
const text = result.content?.[0]?.text ?? '';
174+
// Parse "Weather in <city>: <temp>, <condition>, Humidity: <humidity>"
175+
const match = text.match(/^Weather in (.+?): (.+?), (.+?), Humidity: (.+)$/);
176+
if (!match) return;
177+
const [, city, temp, condition, humidity] = match;
178+
const theme = THEMES[condition.toLowerCase()] ?? { icon: '🌤️', key: 'clear' };
179+
180+
document.body.dataset.condition = theme.key;
181+
document.getElementById('icon').textContent = theme.icon;
182+
document.getElementById('city-name').textContent = city;
183+
document.getElementById('condition').textContent = condition;
184+
document.getElementById('temp').textContent = temp;
185+
document.getElementById('humidity-label').textContent = `Humidity ${humidity}`;
186+
document.getElementById('humidity-fill').style.width = humidity;
187+
document.getElementById('result').classList.add('visible');
188+
}
189+
</script>
190+
</body>
191+
</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
}

0 commit comments

Comments
 (0)