diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e576c87d6a7e..5017a42b2bfa 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -531,6 +531,10 @@ derive_utoipa!(Icon as IconSchema); super::tunnel::TunnelInfo, super::tunnel::TunnelState, super::routes::telemetry::TelemetryEventRequest, + goose::goose_apps::McpAppResource, + goose::goose_apps::CspMetadata, + goose::goose_apps::UiMetadata, + goose::goose_apps::ResourceMetadata, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 8911fbd94b06..94c1d4553d3f 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -116,6 +116,8 @@ pub struct CallToolResponse { content: Vec, structured_content: Option, is_error: bool, + #[serde(skip_serializing_if = "Option::is_none")] + _meta: Option, } #[utoipa::path( @@ -670,6 +672,7 @@ async fn call_tool( content: result.content, structured_content: result.structured_content, is_error: result.is_error.unwrap_or(false), + _meta: None, })) } diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs new file mode 100644 index 000000000000..4fdeb773f2b4 --- /dev/null +++ b/crates/goose/src/goose_apps/mod.rs @@ -0,0 +1,9 @@ +//! goose Apps module +//! +//! This module contains types and utilities for working with goose Apps, +//! which are UI resources that can be rendered in an MCP server or native +//! goose apps, or something in between. + +pub mod resource; + +pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata}; diff --git a/crates/goose/src/goose_apps/resource.rs b/crates/goose/src/goose_apps/resource.rs new file mode 100644 index 000000000000..40d86b291b9a --- /dev/null +++ b/crates/goose/src/goose_apps/resource.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Content Security Policy metadata for MCP Apps +/// Specifies allowed domains for network connections and resource loading +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CspMetadata { + /// Domains allowed for connect-src (fetch, XHR, WebSocket) + #[serde(skip_serializing_if = "Option::is_none")] + pub connect_domains: Option>, + /// Domains allowed for resource loading (scripts, styles, images, fonts, media) + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_domains: Option>, +} + +/// UI-specific metadata for MCP resources +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UiMetadata { + /// Content Security Policy configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub csp: Option, + /// Preferred domain for the app (used for CORS) + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + /// Whether the app prefers to have a border around it + #[serde(skip_serializing_if = "Option::is_none")] + pub prefers_border: Option, +} + +/// Resource metadata containing UI configuration +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ResourceMetadata { + /// UI-specific configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub ui: Option, +} + +/// MCP App Resource +/// Represents a UI resource that can be rendered in an MCP App +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResource { + /// URI of the resource (must use ui:// scheme) + pub uri: String, + /// Human-readable name of the resource + pub name: String, + /// Optional description of what this resource does + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// MIME type (should be "text/html;profile=mcp-app" for MCP Apps) + pub mime_type: String, + /// Text content of the resource (HTML for MCP Apps) + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Base64-encoded binary content (alternative to text) + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + /// Resource metadata including UI configuration + #[serde(skip_serializing_if = "Option::is_none", rename = "_meta")] + pub meta: Option, +} + +impl McpAppResource { + pub fn new_html(uri: String, name: String, html: String) -> Self { + Self { + uri, + name, + description: None, + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(html), + blob: None, + meta: None, + } + } + + pub fn new_html_with_csp( + uri: String, + name: String, + html: String, + csp: CspMetadata, + ) -> Self { + Self { + uri, + name, + description: None, + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(html), + blob: None, + meta: Some(ResourceMetadata { + ui: Some(UiMetadata { + csp: Some(csp), + domain: None, + prefers_border: None, + }), + }), + } + } + + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + pub fn with_ui_metadata(mut self, ui_metadata: UiMetadata) -> Self { + if let Some(meta) = &mut self.meta { + meta.ui = Some(ui_metadata); + } else { + self.meta = Some(ResourceMetadata { + ui: Some(ui_metadata), + }); + } + self + } +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 5e88df237344..4b834c533ff5 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod context_mgmt; pub mod conversation; pub mod execution; +pub mod goose_apps; pub mod hints; pub mod logging; pub mod mcp_utils; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 9645b1f71d8f..88e92221556a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2668,6 +2668,9 @@ "is_error" ], "properties": { + "_meta": { + "nullable": true + }, "content": { "type": "array", "items": { @@ -2908,6 +2911,28 @@ } } }, + "CspMetadata": { + "type": "object", + "description": "Content Security Policy metadata for MCP Apps\nSpecifies allowed domains for network connections and resource loading", + "properties": { + "connectDomains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Domains allowed for connect-src (fetch, XHR, WebSocket)", + "nullable": true + }, + "resourceDomains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Domains allowed for resource loading (scripts, styles, images, fonts, media)", + "nullable": true + } + } + }, "DeclarativeProviderConfig": { "type": "object", "required": [ @@ -3700,6 +3725,52 @@ } } }, + "McpAppResource": { + "type": "object", + "description": "MCP App Resource\nRepresents a UI resource that can be rendered in an MCP App", + "required": [ + "uri", + "name", + "mimeType" + ], + "properties": { + "_meta": { + "allOf": [ + { + "$ref": "#/components/schemas/ResourceMetadata" + } + ], + "nullable": true + }, + "blob": { + "type": "string", + "description": "Base64-encoded binary content (alternative to text)", + "nullable": true + }, + "description": { + "type": "string", + "description": "Optional description of what this resource does", + "nullable": true + }, + "mimeType": { + "type": "string", + "description": "MIME type (should be \"text/html;profile=mcp-app\" for MCP Apps)" + }, + "name": { + "type": "string", + "description": "Human-readable name of the resource" + }, + "text": { + "type": "string", + "description": "Text content of the resource (HTML for MCP Apps)", + "nullable": true + }, + "uri": { + "type": "string", + "description": "URI of the resource (must use ui:// scheme)" + } + } + }, "Message": { "type": "object", "description": "A message to or from an LLM", @@ -4705,6 +4776,20 @@ } ] }, + "ResourceMetadata": { + "type": "object", + "description": "Resource metadata containing UI configuration", + "properties": { + "ui": { + "allOf": [ + { + "$ref": "#/components/schemas/UiMetadata" + } + ], + "nullable": true + } + } + }, "Response": { "type": "object", "properties": { @@ -5618,6 +5703,30 @@ "disabled" ] }, + "UiMetadata": { + "type": "object", + "description": "UI-specific metadata for MCP resources", + "properties": { + "csp": { + "allOf": [ + { + "$ref": "#/components/schemas/CspMetadata" + } + ], + "nullable": true + }, + "domain": { + "type": "string", + "description": "Preferred domain for the app (used for CORS)", + "nullable": true + }, + "prefersBorder": { + "type": "boolean", + "description": "Whether the app prefers to have a border around it", + "nullable": true + } + } + }, "UpdateCustomProviderRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f85a559dcc0e..d8f842c7906d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -53,6 +53,7 @@ export type CallToolRequest = { }; export type CallToolResponse = { + _meta?: unknown; content: Array; is_error: boolean; structured_content?: unknown; @@ -136,6 +137,21 @@ export type CreateScheduleRequest = { recipe_source: string; }; +/** + * Content Security Policy metadata for MCP Apps + * Specifies allowed domains for network connections and resource loading + */ +export type CspMetadata = { + /** + * Domains allowed for connect-src (fetch, XHR, WebSocket) + */ + connectDomains?: Array | null; + /** + * Domains allowed for resource loading (scripts, styles, images, fonts, media) + */ + resourceDomains?: Array | null; +}; + export type DeclarativeProviderConfig = { api_key_env: string; base_url: string; @@ -396,6 +412,38 @@ export type LoadedProvider = { is_editable: boolean; }; +/** + * MCP App Resource + * Represents a UI resource that can be rendered in an MCP App + */ +export type McpAppResource = { + _meta?: ResourceMetadata | null; + /** + * Base64-encoded binary content (alternative to text) + */ + blob?: string | null; + /** + * Optional description of what this resource does + */ + description?: string | null; + /** + * MIME type (should be "text/html;profile=mcp-app" for MCP Apps) + */ + mimeType: string; + /** + * Human-readable name of the resource + */ + name: string; + /** + * Text content of the resource (HTML for MCP Apps) + */ + text?: string | null; + /** + * URI of the resource (must use ui:// scheme) + */ + uri: string; +}; + /** * A message to or from an LLM */ @@ -688,6 +736,13 @@ export type ResourceContents = { uri: string; }; +/** + * Resource metadata containing UI configuration + */ +export type ResourceMetadata = { + ui?: UiMetadata | null; +}; + export type Response = { json_schema?: unknown; }; @@ -991,6 +1046,21 @@ export type TunnelInfo = { export type TunnelState = 'idle' | 'starting' | 'running' | 'error' | 'disabled'; +/** + * UI-specific metadata for MCP resources + */ +export type UiMetadata = { + csp?: CspMetadata | null; + /** + * Preferred domain for the app (used for CORS) + */ + domain?: string | null; + /** + * Whether the app prefers to have a border around it + */ + prefersBorder?: boolean | null; +}; + export type UpdateCustomProviderRequest = { api_key: string; api_url: string; diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index bfc34c4ed1b2..0606de2faf09 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -6,24 +6,11 @@ * @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx */ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useSandboxBridge } from './useSandboxBridge'; import { McpAppResource, ToolInput, ToolInputPartial, ToolResult, ToolCancelled } from './types'; import { cn } from '../../utils'; -import { - handleMessage, - handleOpenLink, - handleNotificationMessage, - handleResourcesList, - handleResourceTemplatesList, - handleResourcesRead, - handlePromptsList, - handlePing, - handleSizeChanged, - handleToolsCall, -} from './utils'; - -const DEFAULT_IFRAME_HEIGHT = 200; +import { DEFAULT_IFRAME_HEIGHT } from './utils'; interface McpAppRendererProps { resource: McpAppResource; @@ -45,7 +32,57 @@ export default function McpAppRenderer({ const prefersBorder = resource._meta?.ui?.prefersBorder ?? true; const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT); - // Note: when @mcp-ui/client SDK provides AppRenderer we will be able to supply these as props to the renderer component + // Handle MCP requests from the guest app + const handleMcpRequest = useCallback( + async (method: string, params: unknown, id?: string | number): Promise => { + console.log(`[MCP App] Request: ${method}`, { params, id }); + + 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/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' }; + } + throw new Error('Invalid params for ui/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}`); + + default: + throw new Error(`Unknown method: ${method}`); + } + }, + [append] + ); + + const handleSizeChanged = useCallback((height: number, _width?: number) => { + const newHeight = Math.max(DEFAULT_IFRAME_HEIGHT, height); + setIframeHeight(newHeight); + }, []); + const { iframeRef, proxyUrl } = useSandboxBridge({ resourceHtml: resource.text || '', resourceCsp: resource._meta?.ui?.csp || null, @@ -54,16 +91,8 @@ export default function McpAppRenderer({ toolInputPartial, toolResult, toolCancelled, - onMessage: handleMessage(append), - onOpenLink: handleOpenLink, - onNotificationMessage: handleNotificationMessage, - onResourcesList: handleResourcesList, - onResourceTemplatesList: handleResourceTemplatesList, - onResourcesRead: handleResourcesRead, - onPromptsList: handlePromptsList, - onPing: handlePing, - onSizeChanged: handleSizeChanged(setIframeHeight), - onToolsCall: handleToolsCall, + onMcpRequest: handleMcpRequest, + onSizeChanged: handleSizeChanged, }); if (!resource) { diff --git a/ui/desktop/src/components/McpApps/types.ts b/ui/desktop/src/components/McpApps/types.ts index 9cfe537b5fe8..fa38d04f644c 100644 --- a/ui/desktop/src/components/McpApps/types.ts +++ b/ui/desktop/src/components/McpApps/types.ts @@ -1,274 +1,64 @@ -// ============================================================================= -// JSON-RPC 2.0 Base Types -// ============================================================================= - -export interface JsonRpcResponse { +// Re-export generated types from Rust +export type { + McpAppResource, + CspMetadata, + UiMetadata, + ResourceMetadata, + CallToolResponse as ToolResult, +} from '../../api/types.gen'; + +export interface JsonRpcRequest { jsonrpc: '2.0'; - id: string | number; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; + id?: string | number; + method: string; + params?: unknown; } export interface JsonRpcNotification { jsonrpc: '2.0'; method: string; - params?: Record; -} - -export type JsonRpcMessage = JsonRpcNotification | JsonRpcResponse; - -// ============================================================================= -// Incoming Guest Messages (discriminated union for type-safe switching) -// ============================================================================= - -export interface SandboxReadyNotification { - jsonrpc: '2.0'; - method: 'ui/notifications/sandbox-ready'; + params?: unknown; } -export interface InitializeRequest { +export interface JsonRpcResponse { jsonrpc: '2.0'; id: string | number; - method: 'ui/initialize'; - params?: Record; -} - -export interface InitializedNotification { - jsonrpc: '2.0'; - method: 'ui/notifications/initialized'; -} - -export interface SizeChangedNotification { - jsonrpc: '2.0'; - method: 'ui/notifications/size-changed'; - params: { - height: number; - width?: number; - }; -} - -export interface OpenLinkRequest { - jsonrpc: '2.0'; - id?: string | number; - method: 'ui/open-link'; - params: { - url: string; - }; -} - -export interface MessageRequest { - jsonrpc: '2.0'; - id?: string | number; - method: 'ui/message'; - params: { - content: { - type: string; - text: string; - }; - }; -} - -type LoggingLevel = - | 'debug' - | 'info' - | 'notice' - | 'warning' - | 'error' - | 'critical' - | 'alert' - | 'emergency'; -export interface LoggingMessageRequest { - jsonrpc: '2.0'; - method: 'notifications/message'; - params: { - _meta?: { [key: string]: unknown }; - data: string; - level: LoggingLevel; - logger?: string; - }; -} - -type ProgressToken = string | number; -interface TaskMetadata { - ttl?: number; -} - -export interface CallToolRequest { - jsonrpc: '2.0'; - id?: string | number; - method: 'tools/call'; - params: { - _meta?: { progressToken?: ProgressToken; [key: string]: unknown }; - arguments?: { [key: string]: unknown }; - name: string; - task?: TaskMetadata; - }; -} - -interface PaginatedRequestParams { - cursor?: string; -} - -export interface ListResourcesRequest { - id?: string | number; - jsonrpc: '2.0'; - method: 'resources/list'; - params?: PaginatedRequestParams; -} - -export interface ListResourceTemplatesRequest { - id?: string | number; - jsonrpc: '2.0'; - method: 'resources/templates/list'; - params?: PaginatedRequestParams; -} - -export interface ReadResourceRequest { - id?: string | number; - jsonrpc: '2.0'; - method: 'resources/read'; - params: { - _meta?: { progressToken?: ProgressToken; [key: string]: unknown }; - uri: string; - }; -} - -export interface ListPromptsRequest { - id?: string | number; - jsonrpc: '2.0'; - method: 'prompts/list'; - params?: PaginatedRequestParams; -} - -export interface PingRequest { - id?: string | number; - jsonrpc: '2.0'; - method: 'ping'; - params?: Record; -} - -export type IncomingGuestMessage = - | SandboxReadyNotification - | InitializeRequest - | InitializedNotification - | SizeChangedNotification - | OpenLinkRequest - | MessageRequest - | LoggingMessageRequest - | CallToolRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | ListPromptsRequest - | PingRequest; - -// ============================================================================= -// MCP App Resource Type -// ============================================================================= - -export interface McpAppResource { - uri: `ui://${string}`; - name: string; - description?: string; - mimeType: 'text/html;profile=mcp-app'; - text?: string; - blob?: string; - _meta?: { - ui?: { - csp?: { - connectDomains?: string[]; - resourceDomains?: string[]; - }; - domain?: `https://${string}`; - prefersBorder?: boolean; - }; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; }; } -// ============================================================================= -// Tool Types -// ============================================================================= - -/** Tool input passed to the MCP App */ -export interface ToolInput { - arguments: Record; -} - -/** Partial/streaming tool input passed to the MCP App */ -export interface ToolInputPartial { - arguments: Record; -} - -/** Tool result passed to the MCP App (matches MCP CallToolResult) */ -export interface ToolResult { - _meta?: Record; - content: unknown[]; - isError?: boolean; - structuredContent?: Record; -} +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; -/** Tool cancellation notification */ -export interface ToolCancelled { - reason?: string; -} - -// ============================================================================= -// Host Context Types -// ============================================================================= - -/** CSP metadata for MCP Apps */ -export interface CspMetadata { - connectDomains?: string[]; - resourceDomains?: string[]; -} - -/** Host context sent to MCP Apps during initialization and updates */ export interface HostContext { - /** Metadata of the tool call that instantiated the App */ toolInfo?: { - /** JSON-RPC id of the tools/call request */ id?: string | number; - /** Contains name, inputSchema, etc… */ tool: { name: string; description?: string; inputSchema?: Record; }; }; - /** Current color theme preference */ theme: 'light' | 'dark'; - /** How the UI is currently displayed - * inline is the only supported mode for now - * can support fullscreen and pip in the future - */ - displayMode: 'inline'; - /** Display modes the host supports */ - availableDisplayModes: ['inline']; - /** Current and maximum dimensions available to the UI */ + displayMode: 'inline' | 'fullscreen' | 'standalone'; + availableDisplayModes: ('inline' | 'fullscreen' | 'standalone')[]; viewport: { width: number; height: number; maxHeight: number; maxWidth: number; }; - /** User's language/region preference (BCP 47, e.g., "en-US") */ locale: string; - /** User's timezone (IANA, e.g., "America/New_York") */ timeZone: string; - /** Host application identifier */ userAgent: string; - /** Platform type for responsive design */ platform: 'web' | 'desktop' | 'mobile'; - /** Device capabilities such as touch */ deviceCapabilities: { touch: boolean; hover: boolean; }; - /** Safe area boundaries in pixels */ safeAreaInsets: { top: number; right: number; @@ -276,3 +66,15 @@ export interface HostContext { left: number; }; } + +export interface ToolInput { + arguments: Record; +} + +export interface ToolInputPartial { + arguments: Record; +} + +export interface ToolCancelled { + reason?: string; +} diff --git a/ui/desktop/src/components/McpApps/useSandboxBridge.ts b/ui/desktop/src/components/McpApps/useSandboxBridge.ts index 4d4f65878cec..a940ecb7da64 100644 --- a/ui/desktop/src/components/McpApps/useSandboxBridge.ts +++ b/ui/desktop/src/components/McpApps/useSandboxBridge.ts @@ -1,40 +1,18 @@ import { useRef, useEffect, useState, useCallback } from 'react'; import type { JsonRpcMessage, - JsonRpcResponse, - IncomingGuestMessage, + JsonRpcRequest, + JsonRpcNotification, ToolInput, ToolInputPartial, ToolResult, ToolCancelled, HostContext, - SizeChangedNotification, - MessageRequest, - OpenLinkRequest, - LoggingMessageRequest, - CallToolRequest, - ListResourcesRequest, - ListResourceTemplatesRequest, - ReadResourceRequest, - ListPromptsRequest, - PingRequest, CspMetadata, } from './types'; -import { - fetchMcpAppProxyUrl, - createSandboxResourceReadyMessage, - createInitializeResponse, - createHostContextChangedNotification, - createToolInputNotification, - createToolInputPartialNotification, - createToolResultNotification, - createToolCancelledNotification, - createResourceTeardownRequest, -} from './utils'; +import { fetchMcpAppProxyUrl } from './utils'; import { useTheme } from '../../contexts/ThemeContext'; - -/** Handler function type that may return a response to send back to the guest */ -type MessageHandler = (msg: T) => JsonRpcResponse | null; +import packageJson from '../../../package.json'; interface SandboxBridgeOptions { resourceHtml: string; @@ -44,16 +22,8 @@ interface SandboxBridgeOptions { toolInputPartial?: ToolInputPartial; toolResult?: ToolResult; toolCancelled?: ToolCancelled; - onMessage?: MessageHandler; - onOpenLink?: MessageHandler; - onNotificationMessage?: MessageHandler; - onToolsCall?: MessageHandler; - onResourcesList?: MessageHandler; - onResourceTemplatesList?: MessageHandler; - onResourcesRead?: MessageHandler; - onPromptsList?: MessageHandler; - onPing?: MessageHandler; - onSizeChanged?: (msg: SizeChangedNotification) => null; + onMcpRequest: (method: string, params: unknown, id?: string | number) => Promise; + onSizeChanged?: (height: number, width?: number) => void; } interface SandboxBridgeResult { @@ -70,24 +40,13 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe toolInputPartial, toolResult, toolCancelled, - onMessage, - onOpenLink, - onNotificationMessage, - onToolsCall, - onResourcesList, - onResourceTemplatesList, - onResourcesRead, - onPromptsList, - onPing, + onMcpRequest, onSizeChanged, } = options; const { resolvedTheme } = useTheme(); - const iframeRef = useRef(null); - const pendingMessagesRef = useRef([]); const isGuestInitializedRef = useRef(false); - const [proxyUrl, setProxyUrl] = useState(null); const [isGuestInitialized, setIsGuestInitialized] = useState(false); @@ -98,248 +57,218 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe useEffect(() => { setIsGuestInitialized(false); isGuestInitializedRef.current = false; - pendingMessagesRef.current = []; }, [resourceUri]); const sendToSandbox = useCallback((message: JsonRpcMessage) => { - const iframe = iframeRef.current; - if (iframe?.contentWindow) { - iframe.contentWindow.postMessage(message, '*'); - } + iframeRef.current?.contentWindow?.postMessage(message, '*'); }, []); - const flushPendingMessages = useCallback(() => { - pendingMessagesRef.current.forEach((msg) => sendToSandbox(msg)); - pendingMessagesRef.current = []; - }, [sendToSandbox]); - const handleJsonRpcMessage = useCallback( - (data: unknown) => { - if (!data || typeof data !== 'object' || !('method' in data)) return; - const msg = data as IncomingGuestMessage; - - console.log(`[Sandbox Bridge] Incoming message: ${msg.method}`, { msg }); - - switch (msg.method) { - case 'ui/notifications/sandbox-ready': { - sendToSandbox(createSandboxResourceReadyMessage(resourceHtml, resourceCsp)); - return; - } - - case 'ui/initialize': { - const iframe = iframeRef.current; - const hostContext: HostContext = { - // TODO: Populate toolInfo when we have tool call context - toolInfo: undefined, - theme: resolvedTheme, - displayMode: 'inline', - availableDisplayModes: ['inline'], - viewport: { - width: iframe?.clientWidth ?? 0, - height: iframe?.clientHeight ?? 0, - maxWidth: window.innerWidth, - maxHeight: window.innerHeight, - }, - locale: navigator.language, - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - userAgent: navigator.userAgent, - platform: 'desktop', - deviceCapabilities: { - touch: 'ontouchstart' in window || navigator.maxTouchPoints > 0, - hover: window.matchMedia('(hover: hover)').matches, - }, - safeAreaInsets: { - top: 0, - right: 0, - bottom: 0, - left: 0, - }, - }; - sendToSandbox(createInitializeResponse(msg.id, hostContext)); + async (data: unknown) => { + if (!data || typeof data !== 'object') return; + + // Handle notifications (no id) + 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; } - case 'ui/notifications/initialized': { + if (msg.method === 'ui/notifications/initialized') { setIsGuestInitialized(true); isGuestInitializedRef.current = true; - flushPendingMessages(); - return; - } - - case 'ui/notifications/size-changed': { - onSizeChanged?.(msg); - return; - } - - case 'ui/open-link': { - const response = onOpenLink?.(msg); - if (response) sendToSandbox(response); - return; - } - - case 'ui/message': { - const response = onMessage?.(msg); - if (response) sendToSandbox(response); - return; - } - - case 'notifications/message': { - const response = onNotificationMessage?.(msg); - if (response) sendToSandbox(response); - return; - } - - case 'tools/call': { - const response = onToolsCall?.(msg); - if (response) sendToSandbox(response); - return; - } - - case 'resources/list': { - const response = onResourcesList?.(msg); - if (response) sendToSandbox(response); return; } - case 'resources/templates/list': { - const response = onResourceTemplatesList?.(msg); - if (response) sendToSandbox(response); + if (msg.method === 'ui/notifications/size-changed') { + const params = msg.params as { height: number; width?: number }; + onSizeChanged?.(params.height, params.width); return; } + } - case 'resources/read': { - const response = onResourcesRead?.(msg); - if (response) sendToSandbox(response); - return; - } + // Handle requests (with id) + if ('method' in data && 'id' in data) { + const msg = data as JsonRpcRequest; - case 'prompts/list': { - const response = onPromptsList?.(msg); - if (response) sendToSandbox(response); - return; - } + try { + if (msg.method === 'ui/initialize') { + if (msg.id === undefined) return; - case 'ping': { - const response = onPing?.(msg); - if (response) sendToSandbox(response); - return; + const iframe = iframeRef.current; + const hostContext: HostContext = { + toolInfo: undefined, + theme: resolvedTheme, + displayMode: 'inline', + availableDisplayModes: ['inline'], + viewport: { + width: iframe?.clientWidth ?? 0, + height: iframe?.clientHeight ?? 0, + maxWidth: window.innerWidth, + maxHeight: window.innerHeight, + }, + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: navigator.userAgent, + platform: 'desktop', + deviceCapabilities: { + touch: 'ontouchstart' in window || navigator.maxTouchPoints > 0, + hover: window.matchMedia('(hover: hover)').matches, + }, + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + }; + + sendToSandbox({ + jsonrpc: '2.0', + id: msg.id, + result: { + protocolVersion: '2025-06-18', + hostCapabilities: { links: true, messages: true }, + hostInfo: { + name: packageJson.productName, + version: packageJson.version, + }, + hostContext, + }, + }); + 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 }); + } + } catch (error) { + console.error(`[Sandbox Bridge] Error handling ${msg.method}:`, error); + if (msg.id !== undefined) { + sendToSandbox({ + jsonrpc: '2.0', + id: msg.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); + } } } }, - [ - resourceHtml, - resourceCsp, - resolvedTheme, - sendToSandbox, - flushPendingMessages, - onToolsCall, - onNotificationMessage, - onOpenLink, - onMessage, - onResourcesList, - onResourceTemplatesList, - onResourcesRead, - onPromptsList, - onPing, - onSizeChanged, - ] + [resourceHtml, resourceCsp, resolvedTheme, sendToSandbox, onMcpRequest, onSizeChanged] ); useEffect(() => { const onMessage = (event: MessageEvent) => { - const iframe = iframeRef.current; - if (!iframe || event.source !== iframe.contentWindow) { - return; - } + if (event.source !== iframeRef.current?.contentWindow) return; handleJsonRpcMessage(event.data); }; - window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [handleJsonRpcMessage]); - // Send tool input when guest is initialized + // Send tool input notification when it changes useEffect(() => { if (!isGuestInitialized || !toolInput) return; - sendToSandbox(createToolInputNotification(toolInput)); + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-input', + params: { arguments: toolInput.arguments }, + }); }, [isGuestInitialized, toolInput, sendToSandbox]); - // Send partial tool input (streaming) when guest is initialized + // Send partial tool input (streaming) notification when it changes useEffect(() => { if (!isGuestInitialized || !toolInputPartial) return; - sendToSandbox(createToolInputPartialNotification(toolInputPartial)); + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-input-partial', + params: { arguments: toolInputPartial.arguments }, + }); }, [isGuestInitialized, toolInputPartial, sendToSandbox]); - // Send tool result when guest is initialized and result is available + // Send tool result notification when it changes useEffect(() => { if (!isGuestInitialized || !toolResult) return; - sendToSandbox(createToolResultNotification(toolResult)); + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: toolResult, + }); }, [isGuestInitialized, toolResult, sendToSandbox]); - // Send tool cancelled notification when toolCancelled changes + // Send tool cancelled notification when it changes useEffect(() => { if (!isGuestInitialized || !toolCancelled) return; - sendToSandbox(createToolCancelledNotification(toolCancelled)); + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-cancelled', + params: toolCancelled.reason ? { reason: toolCancelled.reason } : {}, + }); }, [isGuestInitialized, toolCancelled, sendToSandbox]); - // Send theme changes to sandbox when resolvedTheme changes + // Send theme changes when it changes useEffect(() => { if (!isGuestInitialized) return; - sendToSandbox(createHostContextChangedNotification({ theme: resolvedTheme })); + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { theme: resolvedTheme }, + }); }, [isGuestInitialized, resolvedTheme, sendToSandbox]); - // Watch for viewport size changes useEffect(() => { - if (!isGuestInitialized) return; + if (!isGuestInitialized || !iframeRef.current) return; const iframe = iframeRef.current; - if (!iframe) return; - let lastWidth = iframe.clientWidth; let lastHeight = iframe.clientHeight; - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - const roundedWidth = Math.round(width); - const roundedHeight = Math.round(height); - - if (roundedWidth !== lastWidth || roundedHeight !== lastHeight) { - lastWidth = roundedWidth; - lastHeight = roundedHeight; - sendToSandbox( - createHostContextChangedNotification({ - viewport: { - width: roundedWidth, - height: roundedHeight, - maxWidth: window.innerWidth, - maxHeight: window.innerHeight, - }, - }) - ); - } + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + const w = Math.round(width); + const h = Math.round(height); + + if (w !== lastWidth || h !== lastHeight) { + lastWidth = w; + lastHeight = h; + sendToSandbox({ + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { + viewport: { + width: w, + height: h, + maxWidth: window.innerWidth, + maxHeight: window.innerHeight, + }, + }, + }); } }); - resizeObserver.observe(iframe); - - return () => { - resizeObserver.disconnect(); - }; + observer.observe(iframe); + return () => observer.disconnect(); }, [isGuestInitialized, sendToSandbox]); - // Send resource teardown request when component unmounts + // Cleanup on unmount - use ref to capture latest initialized state useEffect(() => { return () => { if (isGuestInitializedRef.current) { - const { message } = createResourceTeardownRequest('Component unmounting'); - sendToSandbox(message); + sendToSandbox({ + jsonrpc: '2.0', + id: Date.now(), + method: 'ui/resource-teardown', + params: { reason: 'Component unmounting' }, + }); } }; }, [sendToSandbox]); - return { - iframeRef, - proxyUrl, - }; + return { iframeRef, proxyUrl }; } diff --git a/ui/desktop/src/components/McpApps/utils.ts b/ui/desktop/src/components/McpApps/utils.ts index cf0ef656bb52..a999123f1cfa 100644 --- a/ui/desktop/src/components/McpApps/utils.ts +++ b/ui/desktop/src/components/McpApps/utils.ts @@ -1,414 +1,37 @@ -import { - CspMetadata, - HostContext, - JsonRpcNotification, - JsonRpcResponse, - ToolInput, - ToolInputPartial, - ToolResult, - ToolCancelled, - SizeChangedNotification, - OpenLinkRequest, - MessageRequest, - LoggingMessageRequest, - CallToolRequest, - ListResourcesRequest, - ListResourceTemplatesRequest, - ReadResourceRequest, - ListPromptsRequest, - PingRequest, -} from './types'; -import packageJson from '../../../package.json'; - -// ============================================================================= -// JSON-RPC Response Helpers -// ============================================================================= - -/** Standard JSON-RPC error codes */ -export const JsonRpcErrorCode = { - ParseError: -32700, - InvalidRequest: -32600, - MethodNotFound: -32601, - InvalidParams: -32602, - InternalError: -32603, -} as const; - -/** - * Create a successful JSON-RPC response. - */ -export function createSuccessResponse( - id: string | number | undefined, - result: unknown = {} -): JsonRpcResponse | null { - if (id === undefined) { - return null; - } - - return { - jsonrpc: '2.0', - id, - result, - }; -} - -/** - * Create an error JSON-RPC response. - */ -export function createErrorResponse( - id: string | number | undefined, - code: number, - message: string, - data?: unknown -): JsonRpcResponse | null { - if (id === undefined) { - return null; - } - - return { - jsonrpc: '2.0', - id, - error: { - code, - message, - ...(data !== undefined && { data }), - }, - }; -} - -/** Message type that may have an id (for requests that can return responses) */ -type MessageWithOptionalId = { id?: string | number; method: string }; - -/** - * Create a "method not implemented" error response. - */ -export function createNotImplementedResponse(msg: MessageWithOptionalId): JsonRpcResponse | null { - if (msg.id !== undefined) { - return createErrorResponse( - msg.id, - JsonRpcErrorCode.MethodNotFound, - `Method not implemented: ${msg.method}` - ); - } - return null; -} +export const DEFAULT_IFRAME_HEIGHT = 200; /** * Fetch the MCP App proxy URL from the Electron backend. - * - * @param csp - Optional CSP metadata to include in the URL. The outer sandbox - * CSP will be templated to allow these domains, acting as a ceiling - * for what the inner guest UI CSP can permit. - */ -export async function fetchMcpAppProxyUrl(csp?: CspMetadata | null): Promise { + * The proxy enforces CSP as a security boundary for sandboxed apps. + * TODO(Douwe): make this work better with the generated API rather than poking around + */ +export async function fetchMcpAppProxyUrl( + csp?: { + connectDomains?: string[] | null; + resourceDomains?: string[] | null; + } | null +): Promise { try { const baseUrl = await window.electron.getGoosedHostPort(); const secretKey = await window.electron.getSecretKey(); - if (baseUrl && secretKey) { - const params = new URLSearchParams(); - params.set('secret', secretKey); - - // Include CSP domains if provided - if (csp?.connectDomains?.length) { - params.set('connect_domains', csp.connectDomains.join(',')); - } - if (csp?.resourceDomains?.length) { - params.set('resource_domains', csp.resourceDomains.join(',')); - } - - return `${baseUrl}/mcp-app-proxy?${params.toString()}`; + if (!baseUrl || !secretKey) { + console.error('Failed to get goosed host/port or secret key'); + return null; } - console.error('Failed to get goosed host/port or secret key'); - return null; - } catch (error) { - console.error('Error fetching MCP App Proxy URL:', error); - return null; - } -} - -/** - * Create a tool-input notification to send tool arguments to the guest UI. - */ -export function createToolInputNotification(toolInput: ToolInput): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/tool-input', - params: { arguments: toolInput.arguments }, - }; -} - -/** - * Create a tool-result notification to send tool execution result to the guest UI. - */ -export function createToolResultNotification(toolResult: ToolResult): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/tool-result', - params: toolResult as unknown as Record, - }; -} - -/** - * Create a tool-input-partial notification for streaming/partial tool inputs. - * Per spec: Used to send incremental updates of tool arguments as they become available. - */ -export function createToolInputPartialNotification( - toolInputPartial: ToolInputPartial -): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/tool-input-partial', - params: { arguments: toolInputPartial.arguments }, - }; -} - -/** - * Create a tool-cancelled notification when a tool call is cancelled. - * Per spec: Notifies the guest UI that the tool call has been cancelled. - */ -export function createToolCancelledNotification(toolCancelled: ToolCancelled): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/tool-cancelled', - params: toolCancelled.reason ? { reason: toolCancelled.reason } : {}, - }; -} - -let resourceTeardownRequestId = 1; - -/** - * Create a resource-teardown request to notify the guest UI before removal. - * Per spec: Host sends this to allow the guest UI to clean up before being removed. - * Returns a request (with id) that expects a response from the guest. - */ -export function createResourceTeardownRequest(reason: string): { - id: number; - message: JsonRpcNotification & { id: number }; -} { - const id = resourceTeardownRequestId++; - return { - id, - message: { - jsonrpc: '2.0', - id, - method: 'ui/resource-teardown', - params: { reason }, - }, - }; -} - -/** - * Create a sandbox-resource-ready notification to send HTML content to the sandbox. - */ -export function createSandboxResourceReadyMessage( - html: string, - csp: CspMetadata | null -): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/sandbox-resource-ready', - params: { html, csp }, - }; -} -/** - * Create a host-context-changed notification for incremental updates. - * Only the changed fields need to be provided. - */ -export function createHostContextChangedNotification( - hostContext: Partial -): JsonRpcNotification { - return { - jsonrpc: '2.0', - method: 'ui/notifications/host-context-changed', - params: hostContext, - }; -} - -const MCP_PROTOCOL_VERSION = '2025-06-18'; - -/** - * Create an initialize response with host capabilities and context. - */ -export function createInitializeResponse( - requestId: string | number, - hostContext: HostContext -): JsonRpcResponse { - return { - jsonrpc: '2.0', - id: requestId, - result: { - protocolVersion: MCP_PROTOCOL_VERSION, - hostCapabilities: { - links: true, - messages: true, - }, - hostInfo: { - name: packageJson.productName, - version: packageJson.version, - }, - hostContext, - }, - }; -} - -// ============================================================================= -// Message Handlers -// Handlers return JsonRpcResponse | null: -// - Requests (with id) should return a response -// - Notifications (without id) return null -// ============================================================================= - -/** - * Handle ui/message requests from the guest UI. - * Per spec: Host SHOULD add the message to the conversation context, preserving the specified role. - * Host MAY request user consent. - * Returns a factory function that accepts an appendMessage callback. - */ -export function handleMessage(appendMessage?: (text: string) => void) { - return (msg: MessageRequest): JsonRpcResponse | null => { - const text = msg.params.content?.text; - - if (!appendMessage) { - return createErrorResponse( - msg.id, - JsonRpcErrorCode.InternalError, - 'Message handler not available in this context' - ); - } + const params = new URLSearchParams(); + params.set('secret', secretKey); - if (!text) { - return createErrorResponse(msg.id, JsonRpcErrorCode.InvalidParams, 'Missing message text'); + if (csp?.connectDomains?.length) { + params.set('connect_domains', csp.connectDomains.join(',')); } - - try { - appendMessage(text); - window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); - return createSuccessResponse(msg.id, { - status: 'success', - message: 'Message appended successfully', - }); - } catch (error) { - return createErrorResponse( - msg.id, - JsonRpcErrorCode.InternalError, - 'Error appending message', - error - ); + if (csp?.resourceDomains?.length) { + params.set('resource_domains', csp.resourceDomains.join(',')); } - }; -} -/** - * Handle ui/open-link requests from the guest UI. - * Per spec: Host SHOULD open the URL in the user's default browser or a new tab. - */ -export function handleOpenLink(msg: OpenLinkRequest): JsonRpcResponse | null { - try { - window.electron.openExternal(msg.params.url).catch(console.error); - return createSuccessResponse(msg.id, { - status: 'success', - message: 'Link opened successfully', - }); + return `${baseUrl}/mcp-app-proxy?${params.toString()}`; } catch (error) { - console.error('Error opening link:', error); - return createErrorResponse(msg.id, JsonRpcErrorCode.InternalError, 'Error opening link'); - } -} - -/** - * Handle notifications/message from the guest UI. - * Per spec: Log messages to host. This is a standard MCP logging notification. - * Host should forward to the MCP server. - */ -export function handleNotificationMessage(msg: LoggingMessageRequest): JsonRpcResponse | null { - // TODO: Forward to MCP server - console.warn('[MCP Apps] TODO notifications/message: Should forward to MCP server.', { - level: msg.params.level, - data: msg.params.data, - logger: msg.params.logger, - }); - return createNotImplementedResponse(msg); -} - -/** - * Handle tools/call requests from the guest UI. - * Per spec: Execute a tool on the MCP server. Host MUST forward to the MCP server - * that owns this App. Host MUST reject requests for tools that don't include "app" in visibility. - */ -export function handleToolsCall(msg: CallToolRequest): JsonRpcResponse | null { - console.warn('[MCP Apps] TODO tools/call: Should forward to MCP server to execute tool.'); - return createNotImplementedResponse(msg); -} - -/** - * Handle resources/list requests from the guest UI. - * Per spec: List available resources from the MCP server. - * Host MAY forward to MCP server or return cached resource list. - */ -export function handleResourcesList(msg: ListResourcesRequest): JsonRpcResponse | null { - console.warn( - '[MCP Apps] TODO resources/list: Should return list of available resources from MCP server.' - ); - return createNotImplementedResponse(msg); -} - -/** - * Handle resources/templates/list requests from the guest UI. - * Per spec: List available resource templates from the MCP server. - */ -export function handleResourceTemplatesList( - msg: ListResourceTemplatesRequest -): JsonRpcResponse | null { - console.warn( - '[MCP Apps] TODO resources/templates/list: Should return list of resource templates from MCP server.' - ); - return createNotImplementedResponse(msg); -} - -/** - * Handle resources/read requests from the guest UI. - * Per spec: Read resource content from the MCP server. - * This is how Apps fetch data or additional UI resources. - */ -export function handleResourcesRead(msg: ReadResourceRequest): JsonRpcResponse | null { - console.warn('[MCP Apps] TODO resources/read: Should fetch resource content from MCP server.'); - return createNotImplementedResponse(msg); -} - -/** - * Handle prompts/list requests from the guest UI. - * Per spec: List available prompts from the MCP server. - */ -export function handlePromptsList(msg: ListPromptsRequest): JsonRpcResponse | null { - console.warn( - '[MCP Apps] TODO prompts/list: Should return list of available prompts from MCP server.', - { cursor: msg.params?.cursor } - ); - - return createNotImplementedResponse(msg); -} - -/** - * Handle ping requests from the guest UI. - * Per spec: Connection health check. Should forward to MCP server and return its response. - */ -export function handlePing(msg: PingRequest): JsonRpcResponse | null { - // TODO: Forward ping to MCP server and return its response - console.warn('[MCP Apps] TODO ping: Should forward to MCP server and return its response.'); - return createNotImplementedResponse(msg); -} - -const DEFAULT_IFRAME_HEIGHT = 200; - -/** - * Handle ui/notifications/size-changed from the guest UI. - * This is a notification, so no response is sent. - * Returns a handler function that updates iframe height. - */ -export function handleSizeChanged(setIframeHeight: (height: number) => void) { - return (msg: SizeChangedNotification): null => { - const newHeight = Math.max(DEFAULT_IFRAME_HEIGHT, msg.params.height); - setIframeHeight(newHeight); + console.error('Error fetching MCP App Proxy URL:', error); return null; - }; + } }