mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -122,7 +122,6 @@ vi.mock('./contexts/ChatContext', () => ({
|
||||
id: 'test-id',
|
||||
name: 'Test Chat',
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
recipe: null,
|
||||
},
|
||||
setChat: vi.fn(),
|
||||
|
||||
@@ -377,7 +377,6 @@ export function AppInner() {
|
||||
sessionId: '',
|
||||
name: 'Pair Chat',
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
recipe: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
||||
sessionId: '',
|
||||
name: DEFAULT_CHAT_TITLE,
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
recipe: null,
|
||||
recipeParameterValues: null,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user