MCP Apps Plumbing (#6286)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-05 10:34:52 -05:00
committed by GitHub
parent d1f242b69f
commit 85aee9c5b7
21 changed files with 484 additions and 947 deletions
+9 -1
View File
@@ -686,7 +686,12 @@ export type ReadResourceRequest = {
};
export type ReadResourceResponse = {
html: string;
_meta?: {
[key: string]: unknown;
} | null;
mimeType?: string | null;
text: string;
uri: string;
};
export type Recipe = {
@@ -1039,6 +1044,9 @@ export type ToolPermission = {
};
export type ToolRequest = {
_meta?: {
[key: string]: unknown;
};
id: string;
metadata?: {
[key: string]: unknown;
@@ -151,6 +151,7 @@ export default function GooseMessage({
{toolRequests.map((toolRequest) => (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
sessionId={sessionId}
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
@@ -6,14 +6,25 @@
* @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useSandboxBridge } from './useSandboxBridge';
import { McpAppResource, ToolInput, ToolInputPartial, ToolResult, ToolCancelled } from './types';
import {
ToolInput,
ToolInputPartial,
ToolResult,
ToolCancelled,
CspMetadata,
McpMethodParams,
McpMethodResponse,
} from './types';
import { cn } from '../../utils';
import { DEFAULT_IFRAME_HEIGHT } from './utils';
import { readResource, callTool } from '../../api';
interface McpAppRendererProps {
resource: McpAppResource;
resourceUri: string;
extensionName: string;
sessionId: string;
toolInput?: ToolInput;
toolInputPartial?: ToolInputPartial;
toolResult?: ToolResult;
@@ -22,60 +33,126 @@ interface McpAppRendererProps {
}
export default function McpAppRenderer({
resource,
resourceUri,
extensionName,
sessionId,
toolInput,
toolInputPartial,
toolResult,
toolCancelled,
append,
}: McpAppRendererProps) {
const prefersBorder = resource._meta?.ui?.prefersBorder ?? true;
const [resourceHtml, setResourceHtml] = useState<string | null>(null);
const [resourceCsp, setResourceCsp] = useState<CspMetadata | null>(null);
const [error, setError] = useState<string | null>(null);
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
// Handle MCP requests from the guest app
useEffect(() => {
const fetchResource = async () => {
try {
const response = await readResource({
body: {
session_id: sessionId,
uri: resourceUri,
extension_name: extensionName,
},
});
if (response.data) {
const content = response.data;
setResourceHtml(content.text);
const meta = content._meta as { ui?: { csp?: CspMetadata } } | undefined;
setResourceCsp(meta?.ui?.csp || null);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load resource');
}
};
fetchResource();
}, [resourceUri, extensionName, sessionId]);
const handleMcpRequest = useCallback(
async (method: string, params: unknown, id?: string | number): Promise<unknown> => {
console.log(`[MCP App] Request: ${method}`, { params, id });
async (
method: string,
params: Record<string, unknown> = {},
_id?: string | number
): Promise<unknown> => {
switch (method) {
case 'ui/open-link':
if (params && typeof params === 'object' && 'url' in params) {
const { url } = params as { url: string };
window.electron.openExternal(url).catch(console.error);
return { status: 'success', message: 'Link opened successfully' };
}
throw new Error('Invalid params for ui/open-link');
case 'ui/open-link': {
const { url } = params as McpMethodParams['ui/open-link'];
await window.electron.openExternal(url);
return {
status: 'success',
message: 'Link opened successfully',
} satisfies McpMethodResponse['ui/open-link'];
}
case 'ui/message':
if (params && typeof params === 'object' && 'content' in params) {
const content = params.content as { type: string; text: string };
if (!append) {
throw new Error('Message handler not available in this context');
}
if (!content.text) {
throw new Error('Missing message text');
}
append(content.text);
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
return { status: 'success', message: 'Message appended successfully' };
case 'ui/message': {
const { content } = params as McpMethodParams['ui/message'];
if (!append) {
throw new Error('Message handler not available in this context');
}
throw new Error('Invalid params for ui/message');
append(content.text);
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
return {
status: 'success',
message: 'Message appended successfully',
} satisfies McpMethodResponse['ui/message'];
}
case 'tools/call': {
const { name, arguments: args } = params as McpMethodParams['tools/call'];
const fullToolName = `${extensionName}__${name}`;
const response = await callTool({
body: {
session_id: sessionId,
name: fullToolName,
arguments: args || {},
},
});
return {
content: response.data?.content || [],
isError: response.data?.is_error || false,
structuredContent: (response.data as Record<string, unknown>)?.structured_content as
| Record<string, unknown>
| undefined,
} satisfies McpMethodResponse['tools/call'];
}
case 'resources/read': {
const { uri } = params as McpMethodParams['resources/read'];
const response = await readResource({
body: {
session_id: sessionId,
uri,
extension_name: extensionName,
},
});
return {
contents: response.data ? [response.data] : [],
} satisfies McpMethodResponse['resources/read'];
}
case 'notifications/message': {
const { level, logger, data } = params as McpMethodParams['notifications/message'];
console.log(
`[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`,
data
);
return {} satisfies McpMethodResponse['notifications/message'];
}
case 'notifications/message':
case 'tools/call':
case 'resources/list':
case 'resources/templates/list':
case 'resources/read':
case 'prompts/list':
case 'ping':
console.warn(`[MCP App] TODO: ${method} not yet implemented`);
throw new Error(`Method not implemented: ${method}`);
return {} satisfies McpMethodResponse['ping'];
default:
throw new Error(`Unknown method: ${method}`);
}
},
[append]
[append, sessionId, extensionName]
);
const handleSizeChanged = useCallback((height: number, _width?: number) => {
@@ -84,9 +161,9 @@ export default function McpAppRenderer({
}, []);
const { iframeRef, proxyUrl } = useSandboxBridge({
resourceHtml: resource.text || '',
resourceCsp: resource._meta?.ui?.csp || null,
resourceUri: resource.uri,
resourceHtml: resourceHtml || '',
resourceCsp,
resourceUri,
toolInput,
toolInputPartial,
toolResult,
@@ -95,17 +172,26 @@ export default function McpAppRenderer({
onSizeChanged: handleSizeChanged,
});
if (!resource) {
return null;
if (error) {
return (
<div className="mt-3 p-4 border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-red-700 dark:text-red-300">Failed to load MCP app: {error}</div>
</div>
);
}
if (!resourceHtml) {
return (
<div className="mt-3 p-4 border border-borderSubtle rounded-lg bg-bgApp">
<div className="flex items-center justify-center" style={{ minHeight: '200px' }}>
Loading MCP app...
</div>
</div>
);
}
return (
<div
className={cn(
'mt-3 bg-bgApp',
prefersBorder && 'border border-borderSubtle rounded-lg overflow-hidden'
)}
>
<div className={cn('mt-3 bg-bgApp', 'border border-borderSubtle rounded-lg overflow-hidden')}>
{proxyUrl ? (
<iframe
ref={iframeRef}
@@ -1,650 +0,0 @@
import { McpAppResource } from './types';
interface MockResourceListItem {
uri: McpAppResource['uri'];
name: McpAppResource['name'];
description: McpAppResource['description'];
mimeType: McpAppResource['mimeType'];
}
interface MockListedResources {
resources: MockResourceListItem[];
}
interface MockReadResources {
contents: McpAppResource[];
}
const UI_RESOURCE_URI = 'ui://weather-server/dashboard-template' as const;
export const mockToolListResult = {
name: 'get_weather',
description: 'Get current weather for a location',
inputSchema: {
type: 'object',
properties: {
location: { type: 'string' },
},
},
_meta: {
'ui/resourceUri': UI_RESOURCE_URI,
},
};
export const mockResourceListResult: MockListedResources = {
resources: [
{
uri: UI_RESOURCE_URI,
name: 'weather_dashboard',
description: 'Interactive weather dashboard widget',
mimeType: 'text/html;profile=mcp-app',
},
],
};
const mockAppHtml = `<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" id="prism-dark" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" id="prism-light" disabled />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<style>
:root {
--bg-primary: #18181b;
--bg-terminal: #0a0a0a;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--border: #3f3f46;
}
.theme-light {
--bg-primary: #fafafa;
--bg-terminal: #f4f4f5;
--text-primary: #18181b;
--text-secondary: #52525b;
--border: #e4e4e7;
}
.theme-dark {
--bg-primary: #18181b;
--bg-terminal: #0a0a0a;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--border: #3f3f46;
}
html {
overflow: hidden;
}
body {
margin: 0;
padding: 24px 24px 0 24px;
color: var(--text-primary);
background-color: var(--bg-primary);
font-family: "Instrument Serif", system-ui, sans-serif;
font-weight: 400;
font-style: normal;
transition: background-color 0.15s ease, color 0.15s ease;
}
h1 {
font-size: min(max(4rem, 8vw), 8rem);
text-align: center;
line-height: 0.95;
margin: 2rem auto 3rem;
letter-spacing: -0.02em;
}
.host-info-subtitle {
text-align: center;
margin: 0 0 2rem 0;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: var(--text-secondary);
}
.actions {
margin-top: 1rem;
margin-bottom: 2rem;
text-align: center;
}
.actions-heading {
margin: 0 0 0.75rem 0;
letter-spacing: 0.05em;
color: var(--text-primary);
}
.actions-note {
color: var(--text-secondary);
font-size: 1.2rem;
}
.actions-buttons {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--bg-primary);
font-family: ui-monospace, monospace;
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.action-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.action-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.action-btn code {
color: var(--bg-primary);
opacity: 0.7;
font-size: 0.75rem;
}
.action-btn:hover code {
color: var(--text-primary);
opacity: 0.7;
}
.action-btn:disabled {
opacity: 0.25;
cursor: not-allowed;
transform: none;
}
.action-btn:disabled:hover {
background: var(--bg-primary);
transform: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.theme-light .action-btn {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.theme-light .action-btn:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.terminal {
margin: 1.5rem -24px 0 -24px;
background: var(--bg-terminal);
border-top: 1px solid var(--border);
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.5);
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.theme-light .terminal {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
}
.terminal-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
align-items: stretch;
}
.terminal-grid > .terminal-section {
padding: 1.5rem 24px;
}
.terminal-grid > .terminal-section:not(:last-child) {
border-right: 1px solid var(--border);
}
@media (max-width: 700px) {
.terminal-grid > .terminal-section:not(:last-child) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
.terminal-section h2 {
margin: 0 0 0.75rem 0;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #71717a;
}
.terminal-section pre[class*="language-"] {
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;
overflow: visible !important;
}
.terminal-section code[class*="language-"] {
font-family: ui-monospace, monospace !important;
font-size: 0.875rem !important;
white-space: pre-wrap !important;
word-break: break-word !important;
}
/* Dark mode: custom terminal colors */
.theme-dark .terminal-section code[class*="language-"] { color: #e4e4e7 !important; }
.theme-dark .terminal-section .token.property { color: #a78bfa !important; }
.theme-dark .terminal-section .token.string { color: #86efac !important; }
.theme-dark .terminal-section .token.number { color: #fcd34d !important; }
.theme-dark .terminal-section .token.boolean { color: #67e8f9 !important; }
.theme-dark .terminal-section .token.null { color: #f87171 !important; }
.theme-dark .terminal-section .token.punctuation { color: #a1a1aa !important; }
/* Light mode: use Prism default light theme colors */
.theme-light .terminal-section code[class*="language-"] { color: #383a42 !important; }
.theme-light .terminal-section .token.property { color: #7c3aed !important; }
.theme-light .terminal-section .token.string { color: #16a34a !important; }
.theme-light .terminal-section .token.number { color: #ca8a04 !important; }
.theme-light .terminal-section .token.boolean { color: #0891b2 !important; }
.theme-light .terminal-section .token.null { color: #dc2626 !important; }
.theme-light .terminal-section .token.punctuation { color: #71717a !important; }
</style>
</head>
<body>
<p class="host-info-subtitle" id="host-info-subtitle">Connecting...</p>
<h1>MCP App Demo</h1>
<div class="actions">
<h2 class="actions-heading">Host Requests</h2>
<div class="actions-buttons">
<button class="action-btn" id="btn-open-link">
Open Link <code>ui/open-link</code>
</button>
<button class="action-btn" id="btn-message">
Send Message <code>ui/message</code>
</button>
<button class="action-btn" id="btn-size-change">
Size Change <code>ui/notifications/size-changed</code>
</button>
</div>
</div>
<div class="actions">
<h2 class="actions-heading">Server Requests</h2>
<div class="actions-buttons">
<button class="action-btn" id="btn-tools-call">
Call Tool <code>tools/call</code>
</button>
<button class="action-btn" id="btn-resources-list">
List Resources <code>resources/list</code>
</button>
<button class="action-btn" id="btn-resources-templates-list">
List Templates <code>resources/templates/list</code>
</button>
<button class="action-btn" id="btn-resources-read">
Read Resource <code>resources/read</code>
</button>
<button class="action-btn" id="btn-prompts-list">
List Prompts <code>prompts/list</code>
</button>
<button class="action-btn" id="btn-notifications-message">
Log Message <code>notifications/message</code>
</button>
<button class="action-btn" id="btn-ping">
Ping <code>ping</code>
</button>
</div>
</div>
<div class="terminal">
<div class="terminal-grid">
<div class="terminal-section">
<h2>Host Data</h2>
<pre class="language-json"><code class="language-json" id="host-info-content">Initializing...</code></pre>
</div>
<div class="terminal-section">
<h2>Tool Data</h2>
<pre class="language-json"><code class="language-json" id="tool-data-content">Waiting...</code></pre>
</div>
</div>
</div>
<script>
(function() {
let requestId = 1;
const pendingRequests = new Map();
let currentHostInfo = null;
let currentToolData = {
toolInput: null,
toolInputPartial: null,
toolResult: null,
toolCancelled: null
};
function setTheme(theme) {
document.body.classList.remove('theme-light', 'theme-dark');
const prismDark = document.getElementById('prism-dark');
const prismLight = document.getElementById('prism-light');
if (theme === 'light') {
document.body.classList.add('theme-light');
prismDark.disabled = true;
prismLight.disabled = false;
} else {
document.body.classList.add('theme-dark');
prismDark.disabled = false;
prismLight.disabled = true;
}
}
function sendSizeChanged() {
const width = document.body.scrollWidth;
const height = document.body.scrollHeight;
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/size-changed',
params: { width, height }
}, '*');
}
function sendRequest(method, params) {
return new Promise((resolve, reject) => {
const id = requestId++;
pendingRequests.set(id, { resolve, reject });
window.parent.postMessage({
jsonrpc: '2.0',
id: id,
method: method,
params: params
}, '*');
});
}
function sendNotification(method, params) {
window.parent.postMessage({
jsonrpc: '2.0',
method: method,
params: params
}, '*');
}
function renderJson(elementId, data) {
const container = document.getElementById(elementId);
if (!container) return;
const json = JSON.stringify(data, null, 2);
container.textContent = json;
if (typeof Prism !== 'undefined') {
Prism.highlightElement(container);
}
}
function renderHostInfo() {
renderJson('host-info-content', currentHostInfo);
sendSizeChanged();
}
function renderToolData() {
renderJson('tool-data-content', currentToolData);
sendSizeChanged();
}
function initializeHostInfo(result) {
// Update subtitle with host info
const subtitle = document.getElementById('host-info-subtitle');
if (subtitle && result.hostInfo) {
const name = result.hostInfo.name || 'Unknown Host';
const version = result.hostInfo.version || '';
subtitle.textContent = version ? name + ' v' + version : name;
}
currentHostInfo = {
protocolVersion: result.protocolVersion,
hostInfo: result.hostInfo,
hostCapabilities: result.hostCapabilities,
hostContext: result.hostContext || {},
};
renderHostInfo();
}
function updateHostContext(params) {
if (currentHostInfo && currentHostInfo.hostContext) {
Object.assign(currentHostInfo.hostContext, params);
renderHostInfo();
}
}
function handleMessage(event) {
const data = event.data;
if (!data || typeof data !== 'object' || data.jsonrpc !== '2.0') return;
// Handle response to our request
if ('id' in data && pendingRequests.has(data.id)) {
const { resolve, reject } = pendingRequests.get(data.id);
pendingRequests.delete(data.id);
if (data.error) {
reject(data.error);
} else {
resolve(data.result);
}
return;
}
// Handle host-context-changed notification
if (data.method === 'ui/notifications/host-context-changed') {
if (data.params && data.params.theme) {
setTheme(data.params.theme);
}
updateHostContext(data.params);
}
// Handle tool-input notification
if (data.method === 'ui/notifications/tool-input') {
currentToolData.toolInput = data.params;
renderToolData();
}
// Handle tool-result notification
if (data.method === 'ui/notifications/tool-result') {
currentToolData.toolResult = data.params;
renderToolData();
}
// Handle tool-input-partial notification
if (data.method === 'ui/notifications/tool-input-partial') {
currentToolData.toolInputPartial = data.params;
renderToolData();
}
// Handle tool-cancelled notification
if (data.method === 'ui/notifications/tool-cancelled') {
currentToolData.toolCancelled = data.params;
renderToolData();
}
// Handle resource-teardown request
if (data.method === 'ui/resource-teardown') {
console.log('[MockApp] ui/resource-teardown received:', data.params);
// Send response back to host
if ('id' in data) {
window.parent.postMessage({
jsonrpc: '2.0',
id: data.id,
result: {}
}, '*');
}
}
}
async function initialize() {
try {
const result = await sendRequest('ui/initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'MockMcpApp', version: '1.0.0' }
});
// Apply initial theme
if (result.hostContext && result.hostContext.theme) {
setTheme(result.hostContext.theme);
}
initializeHostInfo(result);
// Send initialized notification
sendNotification('ui/notifications/initialized');
} catch (error) {
document.getElementById('host-info-content').textContent = 'Error: ' + error.message;
}
}
window.addEventListener('message', handleMessage);
// Action button: Open Link
document.getElementById('btn-open-link').addEventListener('click', function() {
sendRequest('ui/open-link', {
url: 'https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx'
})
.then(function(result) {
console.log('[MockApp] ui/open-link response:', result);
})
.catch(function(error) {
console.error('[MockApp] ui/open-link error:', error);
});
});
// Action button: Send Message
document.getElementById('btn-message').addEventListener('click', function() {
sendRequest('ui/message', {
role: 'user',
content: {
type: 'text',
text: 'Hello from MCP App Demo! This message was sent via ui/message.'
}
})
.then(function(result) {
console.log('[MockApp] ui/message response:', result);
})
.catch(function(error) {
console.error('[MockApp] ui/message error:', error);
});
});
// Action button: Size Change (reports actual body size)
document.getElementById('btn-size-change').addEventListener('click', function() {
sendSizeChanged();
console.log('[MockApp] ui/notifications/size-changed sent (no response expected)');
});
// Action button: Call Tool
document.getElementById('btn-tools-call').addEventListener('click', function() {
sendRequest('tools/call', {
name: 'example_tool',
arguments: { param1: 'value1', param2: 42 }
})
.then(function(result) {
console.log('[MockApp] tools/call response:', result);
})
.catch(function(error) {
console.error('[MockApp] tools/call error:', error);
});
});
// Action button: List Resources
document.getElementById('btn-resources-list').addEventListener('click', function() {
sendRequest('resources/list', {})
.then(function(result) {
console.log('[MockApp] resources/list response:', result);
})
.catch(function(error) {
console.error('[MockApp] resources/list error:', error);
});
});
// Action button: List Resource Templates
document.getElementById('btn-resources-templates-list').addEventListener('click', function() {
sendRequest('resources/templates/list', {})
.then(function(result) {
console.log('[MockApp] resources/templates/list response:', result);
})
.catch(function(error) {
console.error('[MockApp] resources/templates/list error:', error);
});
});
// Action button: Read Resource
document.getElementById('btn-resources-read').addEventListener('click', function() {
sendRequest('resources/read', {
uri: 'resource://example/demo-resource'
})
.then(function(result) {
console.log('[MockApp] resources/read response:', result);
})
.catch(function(error) {
console.error('[MockApp] resources/read error:', error);
});
});
// Action button: List Prompts
document.getElementById('btn-prompts-list').addEventListener('click', function() {
sendRequest('prompts/list', {})
.then(function(result) {
console.log('[MockApp] prompts/list response:', result);
})
.catch(function(error) {
console.error('[MockApp] prompts/list error:', error);
});
});
// Action button: Log Message
// Note: Per spec this is a notification (no response expected), but we send as request
// during development to get feedback on whether the host handled it.
document.getElementById('btn-notifications-message').addEventListener('click', function() {
sendRequest('notifications/message', {
level: 'info',
data: 'This is a log message from the MCP App Demo!',
logger: 'MockMcpApp'
})
.then(function(result) {
console.log('[MockApp] notifications/message response:', result);
})
.catch(function(error) {
console.error('[MockApp] notifications/message error:', error);
});
});
// Action button: Ping
document.getElementById('btn-ping').addEventListener('click', function() {
sendRequest('ping', {})
.then(function(result) {
console.log('[MockApp] ping response:', result);
})
.catch(function(error) {
console.error('[MockApp] ping error:', error);
});
});
// Send initial size
sendSizeChanged();
// Observe size changes
const resizeObserver = new ResizeObserver(sendSizeChanged);
resizeObserver.observe(document.body);
// Start initialization
initialize();
})();
</script>
</body>
</html>`;
export const mockResourceReadResult: MockReadResources = {
contents: [
{
uri: UI_RESOURCE_URI,
name: 'Demo MCP App',
mimeType: 'text/html;profile=mcp-app',
text: mockAppHtml,
_meta: {
ui: {
csp: {
connectDomains: ['https://api.openweathermap.org'],
resourceDomains: [
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://cdnjs.cloudflare.com',
],
},
prefersBorder: true,
},
},
},
],
};
+21 -10
View File
@@ -1,23 +1,34 @@
// Re-export generated types from Rust
export type {
McpAppResource,
CspMetadata,
UiMetadata,
ResourceMetadata,
CallToolResponse as ToolResult,
} from '../../api/types.gen';
export type { CspMetadata, CallToolResponse as ToolResult } from '../../api/types.gen';
export type McpMethodParams = {
'ui/open-link': { url: string };
'ui/message': { content: { type: string; text: string } };
'tools/call': { name: string; arguments?: Record<string, unknown> };
'resources/read': { uri: string };
'notifications/message': { level?: string; logger?: string; data: unknown };
ping: Record<string, never>;
};
export type McpMethodResponse = {
'ui/open-link': { status: string; message: string };
'ui/message': { status: string; message: string };
'tools/call': { content: unknown[]; isError: boolean; structuredContent?: Record<string, unknown> };
'resources/read': { contents: unknown[] };
'notifications/message': Record<string, never>;
ping: Record<string, never>;
};
export interface JsonRpcRequest {
jsonrpc: '2.0';
id?: string | number;
method: string;
params?: unknown;
params?: Record<string, unknown>;
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: unknown;
params?: Record<string, unknown>;
}
export interface JsonRpcResponse {
@@ -13,6 +13,7 @@ import type {
import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext';
import packageJson from '../../../package.json';
import { errorMessage } from '../../utils/conversionUtils';
interface SandboxBridgeOptions {
resourceHtml: string;
@@ -22,7 +23,11 @@ interface SandboxBridgeOptions {
toolInputPartial?: ToolInputPartial;
toolResult?: ToolResult;
toolCancelled?: ToolCancelled;
onMcpRequest: (method: string, params: unknown, id?: string | number) => Promise<unknown>;
onMcpRequest: (
method: string,
params?: Record<string, unknown>,
id?: string | number
) => Promise<unknown>;
onSizeChanged?: (height: number, width?: number) => void;
}
@@ -48,14 +53,13 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const isGuestInitializedRef = useRef(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
const [isGuestInitialized, setIsGuestInitialized] = useState(false);
useEffect(() => {
fetchMcpAppProxyUrl(resourceCsp).then(setProxyUrl);
}, [resourceCsp]);
// Reset initialization state when resource changes
useEffect(() => {
setIsGuestInitialized(false);
isGuestInitializedRef.current = false;
}, [resourceUri]);
@@ -71,26 +75,26 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
if ('method' in data && !('id' in data)) {
const msg = data as JsonRpcNotification;
if (msg.method === 'ui/notifications/sandbox-ready') {
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-resource-ready',
params: { html: resourceHtml, csp: resourceCsp },
});
return;
}
switch (msg.method) {
case 'ui/notifications/sandbox-ready':
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-resource-ready',
params: { html: resourceHtml, csp: resourceCsp },
});
break;
if (msg.method === 'ui/notifications/initialized') {
setIsGuestInitialized(true);
isGuestInitializedRef.current = true;
return;
}
case 'ui/notifications/initialized':
isGuestInitializedRef.current = true;
break;
if (msg.method === 'ui/notifications/size-changed') {
const params = msg.params as { height: number; width?: number };
onSizeChanged?.(params.height, params.width);
return;
case 'ui/notifications/size-changed': {
const params = msg.params as { height: number; width?: number };
onSizeChanged?.(params.height, params.width);
break;
}
}
return;
}
// Handle requests (with id)
@@ -140,7 +144,6 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
return;
}
// Delegate other requests to handler
const result = await onMcpRequest(msg.method, msg.params, msg.id);
if (msg.id !== undefined) {
sendToSandbox({ jsonrpc: '2.0', id: msg.id, result });
@@ -153,7 +156,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
id: msg.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : 'Unknown error',
message: errorMessage(error),
},
});
}
@@ -174,56 +177,52 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
// Send tool input notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolInput) return;
if (!isGuestInitializedRef.current || !toolInput) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: { arguments: toolInput.arguments },
});
}, [isGuestInitialized, toolInput, sendToSandbox]);
}, [toolInput, sendToSandbox]);
// Send partial tool input (streaming) notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolInputPartial) return;
if (!isGuestInitializedRef.current || !toolInputPartial) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input-partial',
params: { arguments: toolInputPartial.arguments },
});
}, [isGuestInitialized, toolInputPartial, sendToSandbox]);
}, [toolInputPartial, sendToSandbox]);
// Send tool result notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolResult) return;
if (!isGuestInitializedRef.current || !toolResult) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: toolResult,
});
}, [isGuestInitialized, toolResult, sendToSandbox]);
}, [toolResult, sendToSandbox]);
// Send tool cancelled notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolCancelled) return;
if (!isGuestInitializedRef.current || !toolCancelled) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-cancelled',
params: toolCancelled.reason ? { reason: toolCancelled.reason } : {},
});
}, [isGuestInitialized, toolCancelled, sendToSandbox]);
}, [toolCancelled, sendToSandbox]);
// Send theme changes when it changes
useEffect(() => {
if (!isGuestInitialized) return;
if (!isGuestInitializedRef.current) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/host-context-changed',
params: { theme: resolvedTheme },
});
}, [isGuestInitialized, resolvedTheme, sendToSandbox]);
}, [resolvedTheme, sendToSandbox]);
useEffect(() => {
if (!isGuestInitialized || !iframeRef.current) return;
if (!isGuestInitializedRef.current || !iframeRef.current) return;
const iframe = iframeRef.current;
let lastWidth = iframe.clientWidth;
@@ -254,9 +253,8 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
observer.observe(iframe);
return () => observer.disconnect();
}, [isGuestInitialized, sendToSandbox]);
}, [sendToSandbox]);
// Cleanup on unmount - use ref to capture latest initialized state
useEffect(() => {
return () => {
if (isGuestInitializedRef.current) {
@@ -16,6 +16,7 @@ import { TooltipWrapper } from './settings/providers/subcomponents/buttons/Toolt
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
import { isUIResource } from '@mcp-ui/client';
import { CallToolResponse, Content, EmbeddedResource } from '../api';
import McpAppRenderer from './McpApps/McpAppRenderer';
interface ToolGraphNode {
tool: string;
@@ -23,13 +24,36 @@ interface ToolGraphNode {
depends_on: number[];
}
type ToolResultWithMeta = {
status?: string;
value?: CallToolResponse & {
_meta?: {
'ui/resourceUri'?: string;
};
};
};
type ToolRequestWithMeta = ToolRequestMessageContent & {
_meta?: {
'ui/resourceUri'?: string;
};
toolCall: {
status: 'success';
value: {
name: string;
arguments?: Record<string, unknown>;
};
};
};
interface ToolCallWithResponseProps {
sessionId?: string;
isCancelledMessage: boolean;
toolRequest: ToolRequestMessageContent;
toolResponse?: ToolResponseMessageContent;
notifications?: NotificationEvent[];
isStreamingMessage?: boolean;
append?: (value: string) => void; // Function to append messages to the chat
append?: (value: string) => void;
}
function getToolResultContent(toolResult: Record<string, unknown>): Content[] {
@@ -47,7 +71,57 @@ function isEmbeddedResource(content: Content): content is EmbeddedResource {
return 'resource' in content && typeof (content as Record<string, unknown>).resource === 'object';
}
function maybeRenderMCPApp(
toolRequest: ToolRequestMessageContent,
toolResponse: ToolResponseMessageContent | undefined,
sessionId: string,
append?: (value: string) => void
): React.ReactNode {
const requestWithMeta = toolRequest as ToolRequestWithMeta;
let resourceUri = requestWithMeta._meta?.['ui/resourceUri'];
if (!resourceUri && toolResponse) {
const resultWithMeta = toolResponse.toolResult as ToolResultWithMeta;
if (resultWithMeta?.status === 'success' && resultWithMeta.value) {
resourceUri = resultWithMeta.value._meta?.['ui/resourceUri'];
}
}
if (!resourceUri) return null;
if (requestWithMeta.toolCall.status !== 'success') return null;
const extensionName = requestWithMeta.toolCall.value.name.split('__')[0];
let toolResult: CallToolResponse | undefined;
if (toolResponse) {
const resultWithMeta = toolResponse.toolResult as ToolResultWithMeta;
if (resultWithMeta?.status === 'success' && resultWithMeta.value) {
toolResult = resultWithMeta.value;
}
}
return (
<div className="mt-3">
<McpAppRenderer
resourceUri={resourceUri}
toolInput={{ arguments: requestWithMeta.toolCall.value.arguments || {} }}
toolResult={toolResult}
extensionName={extensionName}
sessionId={sessionId}
append={append}
/>
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
MCP Apps are experimental and may change at any time.
</div>
</div>
</div>
);
}
export default function ToolCallWithResponse({
sessionId,
isCancelledMessage,
toolRequest,
toolResponse,
@@ -107,20 +181,7 @@ export default function ToolCallWithResponse({
}
})}
{/* MCP Apps - This data will be coming from a resources/read result. */}
{/* TODO Hook this up */}
{/* {toolResponse?.toolResult &&
mockResourceReadResult.contents.map((content) => {
return (
<McpAppRenderer
resource={content}
key={content.uri}
toolInput={{ arguments: toolCall.arguments }}
toolResult={toolResponse.toolResult.value as unknown as ToolResult}
append={append}
/>
);
})} */}
{sessionId && maybeRenderMCPApp(toolRequest, toolResponse, sessionId, append)}
</>
);
}
@@ -35,11 +35,7 @@ export default function ExtensionsView({
const [searchTerm, setSearchTerm] = useState('');
const { addExtension } = useConfig();
const chatContext = useChatContext();
const sessionId = chatContext?.chat.sessionId || '';
if (!sessionId) {
console.error('ExtensionsView: No session ID available');
}
const sessionId = chatContext?.chat.sessionId;
// Only trigger refresh when deep link config changes AND we don't need to show env vars
useEffect(() => {
@@ -80,20 +76,10 @@ export default function ExtensionsView({
// Close the modal immediately
handleModalClose();
if (!sessionId) {
console.warn('Cannot activate extension without session');
setRefreshKey((prevKey) => prevKey + 1);
return;
}
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension({
addToConfig: addExtension,
extensionConfig: extensionConfig,
sessionId: sessionId,
});
await activateExtension(extensionConfig, addExtension, sessionId);
// Trigger a refresh of the extensions list
setRefreshKey((prevKey) => prevKey + 1);
} catch (error) {
@@ -2,7 +2,6 @@ import React from 'react';
import { MessageSquare, AlertCircle } from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
@@ -44,34 +43,6 @@ export const getToolResponsesMap = (
return responseMap;
};
/**
* Props for the SessionHeaderCard component
*/
export interface SessionHeaderCardProps {
onBack: () => void;
children: React.ReactNode;
}
/**
* Common header card for session views
*/
export const SessionHeaderCard: React.FC<SessionHeaderCardProps> = ({ onBack, children }) => {
return (
<Card className="rounded-none px-8 pt-6 pb-4 bg-background-defaultInverse text-textProminentInverse flex items-center">
<BackButton
showText={false}
onClick={onBack}
size="lg"
className="!text-textProminentInverse dark:!text-textStandardInverse"
/>
{children}
</Card>
);
};
/**
* Props for the SessionMessages component
*/
interface SessionMessagesProps {
messages: Message[];
isLoading: boolean;
@@ -13,10 +13,10 @@ import {
} from './utils';
import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index';
import { ExtensionConfig } from '../../../api/types.gen';
import { ExtensionConfig } from '../../../api';
interface ExtensionSectionProps {
sessionId: string; // Add required sessionId prop
sessionId?: string;
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
hideButtons?: boolean;
@@ -110,7 +110,7 @@ export default function ExtensionsSection({
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
sessionId,
});
setPendingActivationExtensions((prev) => {
@@ -134,11 +134,7 @@ export default function ExtensionsSection({
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension({
addToConfig: addExtension,
extensionConfig: extensionConfig,
sessionId: sessionId,
});
await activateExtension(extensionConfig, addExtension, sessionId);
setPendingActivationExtensions((prev) => {
const updated = new Set(prev);
updated.delete(extensionConfig.name);
@@ -205,7 +201,7 @@ export default function ExtensionsSection({
await deleteExtension({
name,
removeFromConfig: removeExtension,
sessionId: sessionId,
sessionId,
extensionConfig: extensionToDelete ?? undefined,
});
} catch (error) {
@@ -13,11 +13,7 @@ function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
}
interface ActivateExtensionProps {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
extensionConfig: ExtensionConfig;
sessionId: string;
}
type AddExtension = (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
type ExtensionError = {
message?: string;
@@ -60,40 +56,39 @@ async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions =
}
/**
* Activates an extension by adding it to both the config system and the API.
* @param props The extension activation properties
* @returns Promise that resolves when activation is complete
* Activates an extension by adding it config and if a session is set, to the agent
*/
export async function activateExtension({
addToConfig,
extensionConfig,
sessionId,
}: ActivateExtensionProps): Promise<void> {
export async function activateExtension(
extensionConfig: ExtensionConfig,
addExtension: AddExtension,
sessionId?: string
) {
const isBuiltin = isBuiltinExtension(extensionConfig);
try {
// AddToAgent
await addToAgent(extensionConfig, sessionId, true);
} catch (error) {
console.error('Failed to add extension to agent:', error);
await addToConfig(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
if (sessionId) {
try {
await addToAgent(extensionConfig, sessionId, true);
} catch (error) {
console.error('Failed to add extension to agent:', error);
await addExtension(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
}
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
await addExtension(extensionConfig.name, extensionConfig, true);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
} catch (error) {
console.error('Failed to add extension to config:', error);
// remove from Agent
try {
await removeFromAgent(extensionConfig.name, sessionId, true);
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
if (sessionId) {
try {
await removeFromAgent(extensionConfig.name, sessionId, true);
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
}
}
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
// Rethrow the error to inform the caller
throw error;
}
}
@@ -135,7 +130,7 @@ interface UpdateExtensionProps {
removeFromConfig: (name: string) => Promise<void>;
extensionConfig: ExtensionConfig;
originalName?: string;
sessionId: string;
sessionId?: string;
}
/**
@@ -161,7 +156,9 @@ export async function updateExtension({
// First remove the old extension from agent (using original name)
try {
await removeFromAgent(originalName!, sessionId, false);
if (sessionId) {
await removeFromAgent(originalName!, sessionId, false);
}
} catch (error) {
console.error('Failed to remove old extension from agent during rename:', error);
// Continue with the process even if agent removal fails
@@ -182,7 +179,7 @@ export async function updateExtension({
};
// Add new extension with sanitized name
if (enabled) {
if (enabled && sessionId) {
try {
await addToAgent(sanitizedExtensionConfig, sessionId, false);
} catch (error) {
@@ -211,7 +208,7 @@ export async function updateExtension({
name: sanitizedNewName,
};
if (enabled) {
if (enabled && sessionId) {
try {
await addToAgent(sanitizedExtensionConfig, sessionId, false);
} catch (error) {
@@ -255,7 +252,7 @@ interface ToggleExtensionProps {
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
toastOptions?: ToastServiceOptions;
sessionId: string;
sessionId?: string;
}
/**
@@ -274,7 +271,9 @@ export async function toggleExtension({
if (toggle == 'toggleOn') {
try {
// add to agent with toast options
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
if (sessionId) {
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
}
} catch (error) {
console.error('Error adding extension to agent. Attempting to toggle back off.');
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
@@ -301,7 +300,9 @@ export async function toggleExtension({
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
// remove from agent
try {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
if (sessionId) {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
}
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
}
@@ -311,7 +312,9 @@ export async function toggleExtension({
// enabled to disabled
let agentRemoveError = null;
try {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
if (sessionId) {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
}
} catch (error) {
// note there was an error, but attempt to remove from config anyway
console.error('Error removing extension from agent', extensionConfig.name, error);
@@ -347,7 +350,7 @@ export async function toggleExtension({
interface DeleteExtensionProps {
name: string;
removeFromConfig: (name: string) => Promise<void>;
sessionId: string;
sessionId?: string;
extensionConfig?: ExtensionConfig;
}
@@ -364,7 +367,9 @@ export async function deleteExtension({
let agentRemoveError = null;
try {
await removeFromAgent(name, sessionId, true);
if (sessionId) {
await removeFromAgent(name, sessionId, true);
}
} catch (error) {
console.error('Failed to remove extension from agent during deletion:', error);
agentRemoveError = error;