diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs index 05e67f2eb2..f902f21167 100644 --- a/crates/goose-server/src/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -10,7 +10,10 @@ pub async fn check_token( request: Request, next: Next, ) -> Result { - if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" { + if request.uri().path() == "/status" + || request.uri().path() == "/mcp-ui-proxy" + || request.uri().path() == "/mcp-app-proxy" + { return Ok(next.run(request).await); } let secret_key = request diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 751947b019..88ab9d9a88 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -535,6 +535,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 f0bd868db5..ce2d507068 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( @@ -681,6 +683,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-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs new file mode 100644 index 0000000000..bf261127ba --- /dev/null +++ b/crates/goose-server/src/routes/mcp_app_proxy.rs @@ -0,0 +1,118 @@ +use axum::{ + extract::Query, + http::{header, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::get, + Router, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ProxyQuery { + secret: String, + /// Comma-separated list of domains for connect-src (fetch, XHR, WebSocket) + connect_domains: Option, + /// Comma-separated list of domains for resource loading (scripts, styles, images, fonts, media) + resource_domains: Option, +} + +const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html"); + +/// Build the outer sandbox CSP based on declared domains. +/// +/// This CSP acts as a ceiling - the inner guest UI iframe cannot exceed these +/// permissions, even if it tried. This is the single source of truth for +/// security policy enforcement. +/// +/// Based on the MCP Apps specification (ext-apps SEP): +/// +fn build_outer_csp(connect_domains: &[String], resource_domains: &[String]) -> String { + let resources = if resource_domains.is_empty() { + String::new() + } else { + format!(" {}", resource_domains.join(" ")) + }; + + let connections = if connect_domains.is_empty() { + String::new() + } else { + format!(" {}", connect_domains.join(" ")) + }; + + format!( + "default-src 'none'; \ + script-src 'self' 'unsafe-inline'{resources}; \ + script-src-elem 'self' 'unsafe-inline'{resources}; \ + style-src 'self' 'unsafe-inline'{resources}; \ + style-src-elem 'self' 'unsafe-inline'{resources}; \ + connect-src 'self'{connections}; \ + img-src 'self' data: blob:{resources}; \ + font-src 'self'{resources}; \ + media-src 'self' data: blob:{resources}; \ + frame-src blob: data:; \ + object-src 'none'; \ + base-uri 'self'" + ) +} + +/// Parse comma-separated domains, filtering out empty strings +fn parse_domains(domains: Option<&String>) -> Vec { + domains + .map(|d| { + d.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +#[utoipa::path( + get, + path = "/mcp-app-proxy", + params( + ("secret" = String, Query, description = "Secret key for authentication"), + ("connect_domains" = Option, Query, description = "Comma-separated domains for connect-src"), + ("resource_domains" = Option, Query, description = "Comma-separated domains for resource loading") + ), + responses( + (status = 200, description = "MCP App proxy HTML page", content_type = "text/html"), + (status = 401, description = "Unauthorized - invalid or missing secret"), + ) +)] +async fn mcp_app_proxy( + axum::extract::State(secret_key): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + // Parse domains from query params + let connect_domains = parse_domains(params.connect_domains.as_ref()); + let resource_domains = parse_domains(params.resource_domains.as_ref()); + + // Build the outer CSP based on declared domains + let csp = build_outer_csp(&connect_domains, &resource_domains); + + // Replace the CSP placeholder in the HTML template + let html = MCP_APP_PROXY_HTML.replace("{{OUTER_CSP}}", &csp); + + ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + ( + header::HeaderName::from_static("referrer-policy"), + "no-referrer", + ), + ], + Html(html), + ) + .into_response() +} + +pub fn routes(secret_key: String) -> Router { + Router::new() + .route("/mcp-app-proxy", get(mcp_app_proxy)) + .with_state(secret_key) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index ec59ba387d..03039e98df 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod agent; pub mod audio; pub mod config_management; pub mod errors; +pub mod mcp_app_proxy; pub mod mcp_ui_proxy; pub mod recipe; pub mod recipe_utils; @@ -34,5 +35,6 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(setup::routes(state.clone())) .merge(telemetry::routes(state.clone())) .merge(tunnel::routes(state.clone())) - .merge(mcp_ui_proxy::routes(secret_key)) + .merge(mcp_ui_proxy::routes(secret_key.clone())) + .merge(mcp_app_proxy::routes(secret_key)) } diff --git a/crates/goose-server/src/routes/templates/mcp_app_proxy.html b/crates/goose-server/src/routes/templates/mcp_app_proxy.html new file mode 100644 index 0000000000..805ec02659 --- /dev/null +++ b/crates/goose-server/src/routes/templates/mcp_app_proxy.html @@ -0,0 +1,135 @@ + + + + + + + + MCP App Sandbox + + + + + + \ No newline at end of file diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs new file mode 100644 index 0000000000..4fdeb773f2 --- /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 0000000000..a6ea769c74 --- /dev/null +++ b/crates/goose/src/goose_apps/resource.rs @@ -0,0 +1,112 @@ +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 5e88df2373..4b834c533f 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/eslint.config.js b/ui/desktop/eslint.config.js index 391d45a90f..458bcb0a58 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -72,6 +72,9 @@ module.exports = [ HTMLButtonElement: 'readonly', HTMLDivElement: 'readonly', HTMLCanvasElement: 'readonly', + HTMLIFrameElement: 'readonly', + MessageEvent: 'readonly', + StorageEvent: 'readonly', File: 'readonly', FileList: 'readonly', FileReader: 'readonly', @@ -143,4 +146,4 @@ module.exports = [ }, }, }, -]; \ No newline at end of file +]; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ca63e46a53..e780fc6e99 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2698,6 +2698,9 @@ "is_error" ], "properties": { + "_meta": { + "nullable": true + }, "content": { "type": "array", "items": { @@ -2942,6 +2945,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": [ @@ -3734,6 +3759,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", @@ -4807,6 +4878,20 @@ } ] }, + "ResourceMetadata": { + "type": "object", + "description": "Resource metadata containing UI configuration", + "properties": { + "ui": { + "allOf": [ + { + "$ref": "#/components/schemas/UiMetadata" + } + ], + "nullable": true + } + } + }, "Response": { "type": "object", "properties": { @@ -5722,6 +5807,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/App.test.tsx b/ui/desktop/src/App.test.tsx index 92313dfcf8..283b014995 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -122,7 +122,6 @@ vi.mock('./contexts/ChatContext', () => ({ id: 'test-id', name: 'Test Chat', messages: [], - messageHistoryIndex: 0, recipe: null, }, setChat: vi.fn(), diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 766e4afc9f..cffd8c8625 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -377,7 +377,6 @@ export function AppInner() { sessionId: '', name: 'Pair Chat', messages: [], - messageHistoryIndex: 0, recipe: null, }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index ea60af4a5a..6e009a16c5 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; @@ -137,6 +138,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; @@ -397,6 +413,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 */ @@ -708,6 +756,13 @@ export type ResourceContents = { uri: string; }; +/** + * Resource metadata containing UI configuration + */ +export type ResourceMetadata = { + ui?: UiMetadata | null; +}; + export type Response = { json_schema?: unknown; }; @@ -1016,6 +1071,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/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 22a3b9a0c9..7fd02ba198 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -265,27 +265,10 @@ function BaseChatContent({ }); }; - const renderProgressiveMessageList = (chat: ChatType) => ( - <> - m.role === 'user'} - isStreamingMessage={chatState !== ChatState.Idle} - onRenderingComplete={handleRenderingComplete} - onMessageUpdate={onMessageUpdate} - submitElicitationResponse={submitElicitationResponse} - append={(text: string) => handleSubmit(text)} - /> - - ); - const showPopularTopics = messages.length === 0 && !initialMessage && chatState === ChatState.Idle; const chat: ChatType = { - messageHistoryIndex: 0, messages, recipe, sessionId, @@ -375,10 +358,21 @@ function BaseChatContent({ )} - {/* Messages or Popular Topics */} {messages.length > 0 || recipe ? ( <> - {renderProgressiveMessageList(chat)} + + handleSubmit(text)} + isUserMessage={(m: Message) => m.role === 'user'} + isStreamingMessage={chatState !== ChatState.Idle} + onRenderingComplete={handleRenderingComplete} + onMessageUpdate={onMessageUpdate} + submitElicitationResponse={submitElicitationResponse} + /> +
diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index f5d59a54e7..194bafccdf 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import ImagePreview from './ImagePreview'; import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; import { formatMessageTimestamp } from '../utils/timeUtils'; @@ -12,7 +12,7 @@ import { getElicitationContent, NotificationEvent, } from '../types/message'; -import { Message, confirmToolAction } from '../api'; +import { Message } from '../api'; import ToolCallConfirmation from './ToolCallConfirmation'; import ElicitationRequest from './ElicitationRequest'; import MessageCopyLink from './MessageCopyLink'; @@ -20,10 +20,7 @@ import { cn } from '../utils'; import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining'; interface GooseMessageProps { - // messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests - // anything before this index should not render any buttons, but anything after should sessionId: string; - messageHistoryIndex: number; message: Message; messages: Message[]; metadata?: string[]; @@ -38,7 +35,6 @@ interface GooseMessageProps { export default function GooseMessage({ sessionId, - messageHistoryIndex, message, messages, toolCallNotifications, @@ -47,7 +43,6 @@ export default function GooseMessage({ submitElicitationResponse, }: GooseMessageProps) { const contentRef = useRef(null); - const handledToolConfirmations = useRef>(new Set()); let textContent = getTextContent(message); @@ -104,50 +99,6 @@ export default function GooseMessage({ return responseMap; }, [messages, messageIndex, toolRequests]); - useEffect(() => { - if ( - messageIndex === messageHistoryIndex - 1 && - hasToolConfirmation && - toolConfirmationContent && - !handledToolConfirmations.current.has(toolConfirmationContent.data.id) - ) { - const hasExistingResponse = messages.some((msg) => - getToolResponses(msg).some((response) => response.id === toolConfirmationContent.data.id) - ); - - if (!hasExistingResponse) { - handledToolConfirmations.current.add(toolConfirmationContent.data.id); - - void (async () => { - try { - await confirmToolAction({ - body: { - sessionId, - action: 'deny', - id: toolConfirmationContent.data.id, - }, - throwOnError: true, - }); - } catch (error) { - console.error('Failed to send tool cancellation to backend:', error); - const { toastError } = await import('../toasts'); - toastError({ - title: 'Failed to cancel tool', - msg: 'The agent may be waiting for a response. Please try restarting the session.', - }); - } - })(); - } - } - }, [ - messageIndex, - messageHistoryIndex, - hasToolConfirmation, - toolConfirmationContent, - messages, - sessionId, - ]); - return (
@@ -200,10 +151,7 @@ export default function GooseMessage({ {toolRequests.map((toolRequest) => (
)} {hasElicitation && submitElicitationResponse && ( diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx new file mode 100644 index 0000000000..0606de2faf --- /dev/null +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -0,0 +1,136 @@ +/** + * MCP Apps Renderer + * + * Temporary Goose implementation while waiting for official SDK components. + * + * @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx + */ + +import { useState, useCallback } from 'react'; +import { useSandboxBridge } from './useSandboxBridge'; +import { McpAppResource, ToolInput, ToolInputPartial, ToolResult, ToolCancelled } from './types'; +import { cn } from '../../utils'; +import { DEFAULT_IFRAME_HEIGHT } from './utils'; + +interface McpAppRendererProps { + resource: McpAppResource; + toolInput?: ToolInput; + toolInputPartial?: ToolInputPartial; + toolResult?: ToolResult; + toolCancelled?: ToolCancelled; + append?: (text: string) => void; +} + +export default function McpAppRenderer({ + resource, + toolInput, + toolInputPartial, + toolResult, + toolCancelled, + append, +}: McpAppRendererProps) { + const prefersBorder = resource._meta?.ui?.prefersBorder ?? true; + const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT); + + // 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, + resourceUri: resource.uri, + toolInput, + toolInputPartial, + toolResult, + toolCancelled, + onMcpRequest: handleMcpRequest, + onSizeChanged: handleSizeChanged, + }); + + if (!resource) { + return null; + } + + return ( +
+ {proxyUrl ? ( +