* [Examples][Tests] Make the MCP Apps example work over HTTP, and pin it It was the only example without a session store, so every request under `php -S` or php-fpm - each its own process - was answered "Session not found or has expired". It now uses a FileSessionStore like the others. Inspector snapshots pin what the extension actually puts on the wire: the ui:// resource's `text/html;profile=mcp-app` MIME type and `ui` marker, and the `_meta.ui` on the tool that links the two. * Apply suggestion from @chr-hertel * Apply suggestion from @chr-hertel
22 lines
10 KiB
JSON
22 lines
10 KiB
JSON
{
|
|
"contents": [
|
|
{
|
|
"uri": "ui://weather-app",
|
|
"mimeType": "text/html;profile=mcp-app",
|
|
"_meta": {
|
|
"ui": {
|
|
"csp": {
|
|
"connectDomains": [
|
|
"https://api.weather.example.com"
|
|
]
|
|
},
|
|
"permissions": {
|
|
"geolocation": {}
|
|
},
|
|
"prefersBorder": true
|
|
}
|
|
},
|
|
"text": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Weather Dashboard</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n :root {\n --bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%);\n --card-bg: rgba(255, 255, 255, 0.18);\n --card-border: rgba(255, 255, 255, 0.32);\n --text: #ffffff;\n --text-muted: rgba(255, 255, 255, 0.78);\n }\n body[data-condition=\"sunny\"] { --bg: linear-gradient(160deg, #f9a826 0%, #ffd166 100%); }\n body[data-condition=\"partly-cloudy\"]{ --bg: linear-gradient(160deg, #6da6e8 0%, #b8d8ff 100%); }\n body[data-condition=\"cloudy\"] { --bg: linear-gradient(160deg, #6c7a89 0%, #aab7b8 100%); }\n body[data-condition=\"rainy\"] { --bg: linear-gradient(160deg, #3a506b 0%, #5c7c9e 100%); }\n body[data-condition=\"clear\"] { --bg: linear-gradient(160deg, #5b8def 0%, #93c2ff 100%); }\n html, body { background: var(--bg); transition: background 0.6s ease; }\n body {\n font-family: system-ui, -apple-system, sans-serif;\n color: var(--text);\n padding: 1.5rem;\n }\n .form {\n display: flex;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n }\n input {\n flex: 1;\n background: var(--card-bg);\n border: 1px solid var(--card-border);\n border-radius: 10px;\n padding: 0.75rem 1rem;\n font-size: 1rem;\n color: var(--text);\n backdrop-filter: blur(8px);\n }\n input::placeholder { color: var(--text-muted); }\n input:focus { outline: 2px solid rgba(255, 255, 255, 0.5); }\n button {\n background: rgba(255, 255, 255, 0.95);\n color: #2c3e50;\n border: none;\n border-radius: 10px;\n padding: 0.75rem 1.25rem;\n font-size: 1rem;\n font-weight: 600;\n cursor: pointer;\n transition: transform 0.15s ease, opacity 0.15s ease;\n }\n button:hover:not(:disabled) { transform: translateY(-1px); }\n button:disabled { opacity: 0.6; cursor: wait; }\n .card {\n background: var(--card-bg);\n border: 1px solid var(--card-border);\n border-radius: 18px;\n padding: 1.5rem;\n backdrop-filter: blur(12px);\n opacity: 0;\n transform: translateY(8px);\n transition: opacity 0.4s ease, transform 0.4s ease;\n }\n .card.visible { opacity: 1; transform: translateY(0); }\n .city-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 1rem;\n }\n .city { font-size: 1.25rem; font-weight: 600; }\n .icon { font-size: 3rem; line-height: 1; }\n .temp { font-size: 4rem; font-weight: 700; letter-spacing: -2px; line-height: 1; }\n .condition { color: var(--text-muted); margin-top: 0.25rem; }\n .humidity {\n margin-top: 1.25rem;\n font-size: 0.875rem;\n color: var(--text-muted);\n }\n .humidity-bar {\n height: 6px;\n background: rgba(255, 255, 255, 0.18);\n border-radius: 3px;\n margin-top: 0.4rem;\n overflow: hidden;\n }\n .humidity-fill {\n height: 100%;\n background: rgba(255, 255, 255, 0.85);\n border-radius: 3px;\n transition: width 0.6s ease;\n }\n </style>\n</head>\n<body data-condition=\"clear\">\n <div class=\"form\">\n <input type=\"text\" id=\"city\" placeholder=\"Enter city name\" value=\"London\">\n <button id=\"fetch-btn\" onclick=\"fetchWeather()\">Get Weather</button>\n </div>\n <div class=\"card\" id=\"result\">\n <div class=\"city-row\">\n <div>\n <div class=\"city\" id=\"city-name\">—</div>\n <div class=\"condition\" id=\"condition\">—</div>\n </div>\n <div class=\"icon\" id=\"icon\">🌤️</div>\n </div>\n <div class=\"temp\" id=\"temp\">—</div>\n <div class=\"humidity\">\n <span id=\"humidity-label\">Humidity —</span>\n <div class=\"humidity-bar\"><div class=\"humidity-fill\" id=\"humidity-fill\" style=\"width:0%\"></div></div>\n </div>\n </div>\n <script>\n // Map condition strings to emojis and gradient themes.\n const THEMES = {\n sunny: { icon: '☀️', key: 'sunny' },\n clear: { icon: '🌙', key: 'clear' },\n 'partly cloudy': { icon: '⛅', key: 'partly-cloudy' },\n cloudy: { icon: '☁️', key: 'cloudy' },\n rainy: { icon: '🌧️', key: 'rainy' },\n };\n\n // MCP Apps protocol: postMessage with JSONRPCMessage objects between iframe (View) and host.\n // Spec: https://github.com/modelcontextprotocol/ext-apps\n let requestId = 0;\n const pending = new Map();\n\n // Target origin is '*' because the iframe cannot know its parent's origin\n // upfront; the host is responsible for validating event.origin on its side.\n function post(msg) { window.parent.postMessage(msg, '*'); }\n\n function sendRpc(method, params) {\n return new Promise((resolve, reject) => {\n const id = ++requestId;\n pending.set(id, { resolve, reject });\n post({ jsonrpc: '2.0', id, method, params });\n });\n }\n\n function sendNotification(method, params) {\n post({ jsonrpc: '2.0', method, params });\n }\n\n window.addEventListener('message', (event) => {\n // The MCP Apps spec exchanges JSONRPCMessage objects directly.\n // Tolerate stringified payloads too, in case a host serializes them.\n let msg = event.data;\n if (typeof msg === 'string') {\n try { msg = JSON.parse(msg); } catch { return; }\n }\n if (!msg || typeof msg !== 'object') return;\n\n if (msg.id !== undefined && pending.has(msg.id)) {\n const { resolve, reject } = pending.get(msg.id);\n pending.delete(msg.id);\n msg.error ? reject(msg.error) : resolve(msg.result);\n return;\n }\n if (msg.method === 'ui/notifications/tool-input') {\n document.getElementById('city').value = msg.params?.arguments?.city || '';\n } else if (msg.method === 'ui/notifications/tool-result') {\n displayResult(msg.params);\n }\n });\n\n // Spec-mandated handshake — without it, hosts (e.g. Goose) will not forward\n // tool-input / tool-result notifications or tools/call requests.\n (async () => {\n try {\n await sendRpc('ui/initialize', {\n protocolVersion: '2026-01-26',\n capabilities: {},\n clientInfo: { name: 'weather-app', version: '1.0.0' },\n });\n } catch { /* degrade gracefully on non-conforming hosts */ }\n sendNotification('ui/notifications/initialized', {});\n reportSize();\n })();\n\n // Tell the host how tall we want the iframe to be.\n let lastW = 0, lastH = 0;\n function reportSize() {\n const w = Math.ceil(document.documentElement.scrollWidth);\n const h = Math.ceil(document.documentElement.scrollHeight);\n if (w === lastW && h === lastH) return;\n lastW = w; lastH = h;\n sendNotification('ui/notifications/size-changed', { width: w, height: h });\n }\n new ResizeObserver(reportSize).observe(document.body);\n\n async function fetchWeather() {\n const btn = document.getElementById('fetch-btn');\n const city = document.getElementById('city').value.trim();\n if (!city) return;\n btn.disabled = true;\n btn.textContent = 'Loading…';\n try {\n const result = await sendRpc('tools/call', { name: 'get_weather', arguments: { city } });\n if (result) displayResult(result);\n } finally {\n btn.disabled = false;\n btn.textContent = 'Get Weather';\n }\n }\n\n function displayResult(result) {\n const text = result.content?.[0]?.text ?? '';\n // Parse \"Weather in <city>: <temp>, <condition>, Humidity: <humidity>\"\n const match = text.match(/^Weather in (.+?): (.+?), (.+?), Humidity: (.+)$/);\n if (!match) return;\n const [, city, temp, condition, humidity] = match;\n const theme = THEMES[condition.toLowerCase()] ?? { icon: '🌤️', key: 'clear' };\n\n document.body.dataset.condition = theme.key;\n document.getElementById('icon').textContent = theme.icon;\n document.getElementById('city-name').textContent = city;\n document.getElementById('condition').textContent = condition;\n document.getElementById('temp').textContent = temp;\n document.getElementById('humidity-label').textContent = `Humidity ${humidity}`;\n document.getElementById('humidity-fill').style.width = humidity;\n document.getElementById('result').classList.add('visible');\n reportSize();\n }\n </script>\n</body>\n</html>\n"
|
|
}
|
|
]
|
|
}
|