feat: add mcp app renderer (#6095)

Co-authored-by: Douwe Osinga <douwe@block.xyz>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andrew Harvard
2025-12-22 18:01:21 -05:00
committed by GitHub
parent 762b2cf54d
commit 91a6b21f39
29 changed files with 1786 additions and 95 deletions
+4 -1
View File
@@ -10,7 +10,10 @@ pub async fn check_token(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
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
+4
View File
@@ -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;
+3
View File
@@ -116,6 +116,8 @@ pub struct CallToolResponse {
content: Vec<Content>,
structured_content: Option<Value>,
is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
_meta: Option<Value>,
}
#[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,
}))
}
@@ -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<String>,
/// Comma-separated list of domains for resource loading (scripts, styles, images, fonts, media)
resource_domains: Option<String>,
}
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):
/// <https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx>
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<String> {
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<String>, Query, description = "Comma-separated domains for connect-src"),
("resource_domains" = Option<String>, 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<String>,
Query(params): Query<ProxyQuery>,
) -> 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)
}
+3 -1
View File
@@ -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<crate::state::AppState>, 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))
}
@@ -0,0 +1,135 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="referrer" content="no-referrer"/>
<!--
The Content Security Policy is dynamically created by Rust at request time based on the domains declared in the MCP App's resource metadata.
-->
<meta
http-equiv="Content-Security-Policy"
content="{{OUTER_CSP}}"/>
<title>MCP App Sandbox</title>
<style>
body,
html {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<script>
(function () {
'use strict';
let guestIframe = null;
function createGuestIframe(html) {
if (guestIframe) {
guestIframe.remove();
}
guestIframe = document.createElement('iframe');
// Sandbox permissions for the Guest UI
// allow-scripts: needed for the app to run
// allow-same-origin: needed for localStorage, cookies, etc.
// allow-forms: needed if the app has forms
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
guestIframe.srcdoc = html;
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
document
.body
.appendChild(guestIframe);
}
/**
* Handle messages from the Host (parent window).
*/
function handleHostMessage(event) {
if (event.source !== window.parent) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
var method = data.method;
// Handle sandbox-specific notifications from Host
if (method === 'ui/notifications/sandbox-resource-ready') {
var params = data.params || {};
var html = params.html || '';
createGuestIframe(html);
return;
}
// Forward all other messages to Guest UI (if it exists)
// This includes lifecycle messages like responses to ui/initialize
if (guestIframe && guestIframe.contentWindow) {
guestIframe
.contentWindow
.postMessage(data, '*');
}
}
/**
* Handle messages from the Guest UI (inner iframe).
*/
function handleGuestMessage(event) {
if (!guestIframe || event.source !== guestIframe.contentWindow) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
// Forward all messages from Guest UI to Host
// The Sandbox does NOT create its own requests - just relays
window
.parent
.postMessage(data, '*');
}
/**
* Main message handler - routes to appropriate handler.
*/
function handleMessage(event) {
if (event.source === window.parent) {
handleHostMessage(event);
} else if (guestIframe && event.source === guestIframe.contentWindow) {
handleGuestMessage(event);
}
}
// Set up message listener
window.addEventListener('message', handleMessage);
// Notify Host that Sandbox is ready
window
.parent
.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-ready',
params: {}
}, '*');
})();
</script>
</body>
</html>
+9
View File
@@ -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};
+112
View File
@@ -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<Vec<String>>,
/// Domains allowed for resource loading (scripts, styles, images, fonts, media)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_domains: Option<Vec<String>>,
}
/// 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<CspMetadata>,
/// Preferred domain for the app (used for CORS)
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
/// Whether the app prefers to have a border around it
#[serde(skip_serializing_if = "Option::is_none")]
pub prefers_border: Option<bool>,
}
/// 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<UiMetadata>,
}
/// 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<String>,
/// 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<String>,
/// Base64-encoded binary content (alternative to text)
#[serde(skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Resource metadata including UI configuration
#[serde(skip_serializing_if = "Option::is_none", rename = "_meta")]
pub meta: Option<ResourceMetadata>,
}
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
}
}
+1
View File
@@ -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;
+4 -1
View File
@@ -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 = [
},
},
},
];
];
+109
View File
@@ -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": [
-1
View File
@@ -122,7 +122,6 @@ vi.mock('./contexts/ChatContext', () => ({
id: 'test-id',
name: 'Test Chat',
messages: [],
messageHistoryIndex: 0,
recipe: null,
},
setChat: vi.fn(),
-1
View File
@@ -377,7 +377,6 @@ export function AppInner() {
sessionId: '',
name: 'Pair Chat',
messages: [],
messageHistoryIndex: 0,
recipe: null,
});
+70
View File
@@ -53,6 +53,7 @@ export type CallToolRequest = {
};
export type CallToolResponse = {
_meta?: unknown;
content: Array<Content>;
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<string> | null;
/**
* Domains allowed for resource loading (scripts, styles, images, fonts, media)
*/
resourceDomains?: Array<string> | 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;
+13 -19
View File
@@ -265,27 +265,10 @@ function BaseChatContent({
});
};
const renderProgressiveMessageList = (chat: ChatType) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
toolCallNotifications={toolCallNotifications}
isUserMessage={(m: Message) => 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({
</div>
)}
{/* Messages or Popular Topics */}
{messages.length > 0 || recipe ? (
<>
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
<SearchView>
<ProgressiveMessageList
messages={messages}
chat={{ sessionId }}
toolCallNotifications={toolCallNotifications}
append={(text: string) => handleSubmit(text)}
isUserMessage={(m: Message) => m.role === 'user'}
isStreamingMessage={chatState !== ChatState.Idle}
onRenderingComplete={handleRenderingComplete}
onMessageUpdate={onMessageUpdate}
submitElicitationResponse={submitElicitationResponse}
/>
</SearchView>
<div className="block h-8" />
</>
+7 -59
View File
@@ -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<HTMLDivElement | null>(null);
const handledToolConfirmations = useRef<Set<string>>(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 (
<div className="goose-message flex w-[90%] justify-start min-w-0">
<div className="flex flex-col w-full min-w-0">
@@ -200,10 +151,7 @@ export default function GooseMessage({
{toolRequests.map((toolRequest) => (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
@@ -223,16 +171,16 @@ export default function GooseMessage({
{hasToolConfirmation && (
<ToolCallConfirmation
sessionId={sessionId}
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
isClicked={messageIndex < messageHistoryIndex}
isCancelledMessage={false}
isClicked={false}
actionRequiredContent={toolConfirmationContent}
/>
)}
{hasElicitation && submitElicitationResponse && (
<ElicitationRequest
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
isClicked={messageIndex < messageHistoryIndex}
isCancelledMessage={false}
isClicked={false}
actionRequiredContent={elicitationContent}
onSubmit={submitElicitationResponse}
/>
@@ -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<unknown> => {
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 (
<div
className={cn(
'mt-3 bg-bgApp',
prefersBorder && 'border border-borderSubtle rounded-lg overflow-hidden'
)}
>
{proxyUrl ? (
<iframe
ref={iframeRef}
src={proxyUrl}
style={{
width: '100%',
height: `${iframeHeight}px`,
border: 'none',
overflow: 'hidden',
}}
sandbox="allow-scripts allow-same-origin"
/>
) : (
<div
style={{
width: '100%',
minHeight: '200px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Loading...
</div>
)}
</div>
);
}
@@ -0,0 +1,650 @@
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,
},
},
},
],
};
@@ -0,0 +1,80 @@
// 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;
method: string;
params?: unknown;
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: unknown;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
export interface HostContext {
toolInfo?: {
id?: string | number;
tool: {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
};
};
theme: 'light' | 'dark';
displayMode: 'inline' | 'fullscreen' | 'standalone';
availableDisplayModes: ('inline' | 'fullscreen' | 'standalone')[];
viewport: {
width: number;
height: number;
maxHeight: number;
maxWidth: number;
};
locale: string;
timeZone: string;
userAgent: string;
platform: 'web' | 'desktop' | 'mobile';
deviceCapabilities: {
touch: boolean;
hover: boolean;
};
safeAreaInsets: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface ToolInput {
arguments: Record<string, unknown>;
}
export interface ToolInputPartial {
arguments: Record<string, unknown>;
}
export interface ToolCancelled {
reason?: string;
}
@@ -0,0 +1,274 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import type {
JsonRpcMessage,
JsonRpcRequest,
JsonRpcNotification,
ToolInput,
ToolInputPartial,
ToolResult,
ToolCancelled,
HostContext,
CspMetadata,
} from './types';
import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext';
import packageJson from '../../../package.json';
interface SandboxBridgeOptions {
resourceHtml: string;
resourceCsp: CspMetadata | null;
resourceUri: string;
toolInput?: ToolInput;
toolInputPartial?: ToolInputPartial;
toolResult?: ToolResult;
toolCancelled?: ToolCancelled;
onMcpRequest: (method: string, params: unknown, id?: string | number) => Promise<unknown>;
onSizeChanged?: (height: number, width?: number) => void;
}
interface SandboxBridgeResult {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
proxyUrl: string | null;
}
export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeResult {
const {
resourceHtml,
resourceCsp,
resourceUri,
toolInput,
toolInputPartial,
toolResult,
toolCancelled,
onMcpRequest,
onSizeChanged,
} = options;
const { resolvedTheme } = useTheme();
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]);
useEffect(() => {
setIsGuestInitialized(false);
isGuestInitializedRef.current = false;
}, [resourceUri]);
const sendToSandbox = useCallback((message: JsonRpcMessage) => {
iframeRef.current?.contentWindow?.postMessage(message, '*');
}, []);
const handleJsonRpcMessage = useCallback(
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;
}
if (msg.method === 'ui/notifications/initialized') {
setIsGuestInitialized(true);
isGuestInitializedRef.current = true;
return;
}
if (msg.method === 'ui/notifications/size-changed') {
const params = msg.params as { height: number; width?: number };
onSizeChanged?.(params.height, params.width);
return;
}
}
// Handle requests (with id)
if ('method' in data && 'id' in data) {
const msg = data as JsonRpcRequest;
try {
if (msg.method === 'ui/initialize') {
if (msg.id === undefined) 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, onMcpRequest, onSizeChanged]
);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
handleJsonRpcMessage(event.data);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [handleJsonRpcMessage]);
// Send tool input notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolInput) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: { arguments: toolInput.arguments },
});
}, [isGuestInitialized, toolInput, sendToSandbox]);
// Send partial tool input (streaming) notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolInputPartial) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input-partial',
params: { arguments: toolInputPartial.arguments },
});
}, [isGuestInitialized, toolInputPartial, sendToSandbox]);
// Send tool result notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolResult) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: toolResult,
});
}, [isGuestInitialized, toolResult, sendToSandbox]);
// Send tool cancelled notification when it changes
useEffect(() => {
if (!isGuestInitialized || !toolCancelled) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-cancelled',
params: toolCancelled.reason ? { reason: toolCancelled.reason } : {},
});
}, [isGuestInitialized, toolCancelled, sendToSandbox]);
// Send theme changes when it changes
useEffect(() => {
if (!isGuestInitialized) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/host-context-changed',
params: { theme: resolvedTheme },
});
}, [isGuestInitialized, resolvedTheme, sendToSandbox]);
useEffect(() => {
if (!isGuestInitialized || !iframeRef.current) return;
const iframe = iframeRef.current;
let lastWidth = iframe.clientWidth;
let lastHeight = iframe.clientHeight;
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,
},
},
});
}
});
observer.observe(iframe);
return () => observer.disconnect();
}, [isGuestInitialized, sendToSandbox]);
// Cleanup on unmount - use ref to capture latest initialized state
useEffect(() => {
return () => {
if (isGuestInitializedRef.current) {
sendToSandbox({
jsonrpc: '2.0',
id: Date.now(),
method: 'ui/resource-teardown',
params: { reason: 'Component unmounting' },
});
}
};
}, [sendToSandbox]);
return { iframeRef, proxyUrl };
}
@@ -0,0 +1,37 @@
export const DEFAULT_IFRAME_HEIGHT = 200;
/**
* Fetch the MCP App proxy URL from the Electron backend.
* 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<string | null> {
try {
const baseUrl = await window.electron.getGoosedHostPort();
const secretKey = await window.electron.getSecretKey();
if (!baseUrl || !secretKey) {
console.error('Failed to get goosed host/port or secret key');
return null;
}
const params = new URLSearchParams();
params.set('secret', secretKey);
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()}`;
} catch (error) {
console.error('Error fetching MCP App Proxy URL:', error);
return null;
}
}
@@ -26,7 +26,7 @@ import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaini
interface ProgressiveMessageListProps {
messages: Message[];
chat?: Pick<ChatType, 'sessionId' | 'messageHistoryIndex'>;
chat: Pick<ChatType, 'sessionId'>;
toolCallNotifications?: Map<string, NotificationEvent[]>; // Make optional
append?: (value: string) => void; // Make optional
isUserMessage: (message: Message) => boolean;
@@ -217,7 +217,6 @@ export default function ProgressiveMessageList({
) : (
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
@@ -106,6 +106,21 @@ export default function ToolCallWithResponse({
return null;
}
})}
{/* 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}
/>
);
})} */}
</>
);
}
@@ -106,7 +106,6 @@ const SessionMessages: React.FC<{
messages={filteredMessages}
chat={{
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
@@ -256,7 +256,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</div>
</div>
)}
</CardContent>
</Card>
-1
View File
@@ -39,7 +39,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
sessionId: '',
name: DEFAULT_CHAT_TITLE,
messages: [],
messageHistoryIndex: 0,
recipe: null,
recipeParameterValues: null,
});
-2
View File
@@ -116,7 +116,6 @@ export function useAgent(): UseAgentReturn {
return {
sessionId: agentSession.id,
name: agentSession.recipe?.title || agentSession.name,
messageHistoryIndex: 0,
messages,
recipe: agentSession.recipe,
recipeParameterValues: agentSession.user_recipe_values || null,
@@ -241,7 +240,6 @@ export function useAgent(): UseAgentReturn {
let initChat: ChatType = {
sessionId: agentSession.id,
name: agentSession.recipe?.title || agentSession.name,
messageHistoryIndex: 0,
messages: messages,
recipe: recipe,
recipeParameterValues: agentSession.user_recipe_values || null,
+1 -4
View File
@@ -42,10 +42,7 @@ export const useCostTracking = ({
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
// Get pricing info for the previous model
const prevCostInfo = await fetchModelPricing(
prevProviderRef.current,
prevModelRef.current
);
const prevCostInfo = await fetchModelPricing(prevProviderRef.current, prevModelRef.current);
if (prevCostInfo) {
const prevInputCost =
-1
View File
@@ -4,7 +4,6 @@ import { Message } from '../api';
export interface ChatType {
sessionId: string;
name: string;
messageHistoryIndex: number;
messages: Message[];
recipe?: Recipe | null; // Add recipe configuration to chat state
resolvedRecipe?: Recipe | null; // Add resolved recipe with parameter values rendered to chat state