diff --git a/desktop/package.json b/desktop/package.json index 6726bfdcad..12d87ae410 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -31,6 +31,8 @@ "@emoji-mart/react": "^1.1.1", "@fontsource-variable/inter": "^5.2.8", "@mediapipe/tasks-vision": "^0.10.35", + "@modelcontextprotocol/ext-apps": "1.7.5", + "@modelcontextprotocol/sdk": "1.29.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/desktop/src-tauri/src/commands/mcp_apps.rs b/desktop/src-tauri/src/commands/mcp_apps.rs new file mode 100644 index 0000000000..37203d7349 --- /dev/null +++ b/desktop/src-tauri/src/commands/mcp_apps.rs @@ -0,0 +1,965 @@ +//! MCP Apps host transport and sandbox registration. +//! +//! The webview never receives MCP credentials or a general-purpose network +//! primitive. Rust owns the reviewed server connection and exposes only the +//! MCP methods required by the Apps protocol. + +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use base64::Engine; +use futures_util::StreamExt; +use reqwest::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{http, AppHandle, Manager, State}; +use tokio::sync::Mutex as AsyncMutex; +use url::Url; +use uuid::Uuid; + +const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app"; +const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +const MAX_MCP_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const MAX_MCP_APP_HTML_BYTES: usize = 4 * 1024 * 1024; +const MAX_SERVERS: usize = 16; +const MAX_VIEWS: usize = 32; +const MAX_TOOLS: usize = 256; +const MAX_RESOURCES: usize = 256; + +const SANDBOX_PROXY_HTML: &str = r#" + + + + + + + + + +"#; + +#[derive(Debug, Clone)] +struct McpServerConnection { + endpoint: Url, + client: Client, + protocol_version: String, + session_id: Option, + next_request_id: Arc, + tools: Vec, + resources: Vec, +} + +#[derive(Debug, Clone)] +struct ViewPolicy { + server_id: String, + csp: String, +} + +/// Runtime state for reviewed MCP servers and isolated app views. +pub struct McpAppHostState { + servers: AsyncMutex>, + views: Mutex>, +} + +impl Default for McpAppHostState { + fn default() -> Self { + Self { + servers: AsyncMutex::new(HashMap::new()), + views: Mutex::new(HashMap::new()), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppTool { + name: String, + title: Option, + description: Option, + input_schema: Value, + output_schema: Option, + annotations: Option, + meta: Value, + ui_resource_uri: Option, + visibility: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResource { + uri: String, + name: Option, + title: Option, + description: Option, + mime_type: Option, + meta: Value, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppServerDescriptor { + server_id: String, + endpoint: String, + name: String, + version: Option, + protocol_version: String, + tools: Vec, + resources: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResourceCsp { + #[serde(default)] + connect_domains: Vec, + #[serde(default)] + resource_domains: Vec, + #[serde(default)] + frame_domains: Vec, + #[serde(default)] + base_uri_domains: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResourcePermissions { + camera: Option, + microphone: Option, + geolocation: Option, + clipboard_write: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedMcpAppView { + view_id: String, + sandbox_url: String, + html: String, + csp: McpAppResourceCsp, + /// Permissions are reported for review but not granted by this host layer. + requested_permissions: McpAppResourcePermissions, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpAppToolCaller { + Host, + App, +} + +#[derive(Debug)] +struct McpWireResponse { + value: Option, + session_id: Option, +} + +fn text(value: Option<&Value>) -> Option { + value? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn ui_resource_uri(meta: &Value) -> Option { + let nested = meta + .get("ui") + .and_then(|ui| ui.get("resourceUri")) + .and_then(Value::as_str); + let legacy = meta.get("ui/resourceUri").and_then(Value::as_str); + nested + .or(legacy) + .filter(|uri| uri.starts_with("ui://")) + .map(ToOwned::to_owned) +} + +fn tool_visibility(meta: &Value) -> Vec { + let visibility = meta + .get("ui") + .and_then(|ui| ui.get("visibility")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|value| matches!(*value, "model" | "app")) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + if visibility.is_empty() { + vec!["model".to_string(), "app".to_string()] + } else { + visibility + } +} + +fn parse_tools(value: &Value) -> Result, String> { + value + .pointer("/result/tools") + .and_then(Value::as_array) + .ok_or_else(|| "MCP tools/list response is missing result.tools".to_string())? + .iter() + .take(MAX_TOOLS) + .map(|tool| { + let name = text(tool.get("name")) + .ok_or_else(|| "MCP tool is missing a valid name".to_string())?; + let meta = tool.get("_meta").cloned().unwrap_or_else(|| json!({})); + Ok(McpAppTool { + name, + title: text(tool.get("title")), + description: text(tool.get("description")), + input_schema: tool + .get("inputSchema") + .cloned() + .unwrap_or_else(|| json!({"type": "object", "properties": {}})), + output_schema: tool.get("outputSchema").cloned(), + annotations: tool.get("annotations").cloned(), + ui_resource_uri: ui_resource_uri(&meta), + visibility: tool_visibility(&meta), + meta, + }) + }) + .collect() +} + +fn parse_resources(value: &Value) -> Result, String> { + value + .pointer("/result/resources") + .and_then(Value::as_array) + .ok_or_else(|| "MCP resources/list response is missing result.resources".to_string())? + .iter() + .take(MAX_RESOURCES) + .map(|resource| { + let uri = text(resource.get("uri")) + .ok_or_else(|| "MCP resource is missing a valid URI".to_string())?; + Ok(McpAppResource { + uri, + name: text(resource.get("name")), + title: text(resource.get("title")), + description: text(resource.get("description")), + mime_type: text(resource.get("mimeType")), + meta: resource.get("_meta").cloned().unwrap_or_else(|| json!({})), + }) + }) + .collect() +} + +fn is_private_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_unspecified() + || ip.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) +} + +fn is_private_ipv6(ip: Ipv6Addr) -> bool { + if let Some(mapped) = ip.to_ipv4_mapped() { + return is_private_ipv4(mapped); + } + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0]) + || segments[0] == 0x2002 + || (segments[0] == 0x2001 && segments[1] == 0) +} + +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_private_ipv4(ip), + IpAddr::V6(ip) => is_private_ipv6(ip), + } +} + +fn validate_mcp_endpoint(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|error| format!("invalid MCP server URL: {error}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("MCP server URL must not include credentials".to_string()); + } + if url.fragment().is_some() { + return Err("MCP server URL must not include a fragment".to_string()); + } + let host = url + .host_str() + .ok_or_else(|| "MCP server URL is missing a host".to_string())?; + let loopback_host = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()); + match url.scheme() { + "https" if !loopback_host => {} + "http" if loopback_host => {} + _ => { + return Err( + "MCP servers require HTTPS; HTTP is allowed only for loopback development" + .to_string(), + ) + } + } + if host.ends_with(".local") || host.contains('%') { + return Err("MCP server host is not allowed".to_string()); + } + Ok(url) +} + +async fn build_pinned_client(endpoint: &Url) -> Result { + let host = endpoint + .host_str() + .ok_or_else(|| "MCP server URL is missing a host".to_string())?; + let port = endpoint + .port_or_known_default() + .ok_or_else(|| "MCP server URL is missing a port".to_string())?; + let loopback = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()); + let addresses = tokio::net::lookup_host((host, port)) + .await + .map_err(|error| format!("failed to resolve MCP server: {error}"))? + .collect::>(); + if addresses.is_empty() { + return Err("MCP server did not resolve to an address".to_string()); + } + if loopback { + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err("loopback MCP server resolved outside loopback".to_string()); + } + } else if addresses.iter().any(|address| is_private_ip(address.ip())) { + return Err("MCP server resolved to a private or reserved address".to_string()); + } + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .pool_idle_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(2); + if host.parse::().is_err() { + builder = builder.resolve_to_addrs(host, &addresses); + } + builder + .build() + .map_err(|error| format!("failed to build MCP HTTP client: {error}")) +} + +async fn read_capped_response(response: Response) -> Result { + let status = response.status(); + let headers = response.headers().clone(); + if !status.is_success() { + return Err(format!("MCP server returned HTTP {status}")); + } + let session_id = headers + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + if status == reqwest::StatusCode::ACCEPTED { + return Ok(McpWireResponse { + value: None, + session_id, + }); + } + if response + .content_length() + .is_some_and(|length| length > MAX_MCP_RESPONSE_BYTES as u64) + { + return Err("MCP response exceeds the 4 MiB limit".to_string()); + } + let content_type = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("failed to read MCP response: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > MAX_MCP_RESPONSE_BYTES { + return Err("MCP response exceeds the 4 MiB limit".to_string()); + } + bytes.extend_from_slice(&chunk); + } + let value = if content_type.starts_with("text/event-stream") { + let text = String::from_utf8(bytes) + .map_err(|_| "MCP event stream is not valid UTF-8".to_string())?; + text.lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|line| !line.is_empty()) + .find_map(|line| serde_json::from_str::(line).ok()) + .ok_or_else(|| "MCP event stream did not contain a JSON response".to_string())? + } else { + serde_json::from_slice(&bytes) + .map_err(|error| format!("MCP response is not valid JSON: {error}"))? + }; + if let Some(error) = value.get("error") { + return Err(format!("MCP request failed: {error}")); + } + Ok(McpWireResponse { + value: Some(value), + session_id, + }) +} + +async fn post_mcp( + client: &Client, + endpoint: &Url, + protocol_version: Option<&str>, + session_id: Option<&str>, + payload: &Value, +) -> Result { + let mut request = client + .post(endpoint.clone()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .json(payload); + if let Some(protocol_version) = protocol_version { + request = request.header("mcp-protocol-version", protocol_version); + } + if let Some(session_id) = session_id { + request = request.header("mcp-session-id", session_id); + } + let response = request + .send() + .await + .map_err(|error| format!("MCP request failed: {error}"))?; + read_capped_response(response).await +} + +async fn request( + connection: &McpServerConnection, + method: &str, + params: Value, +) -> Result { + let id = connection.next_request_id.fetch_add(1, Ordering::Relaxed); + let response = post_mcp( + &connection.client, + &connection.endpoint, + Some(&connection.protocol_version), + connection.session_id.as_deref(), + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }), + ) + .await?; + response + .value + .ok_or_else(|| format!("MCP {method} returned no response")) +} + +fn resource_meta(content: &Value, listing: Option<&McpAppResource>) -> Value { + content + .get("_meta") + .or_else(|| content.get("meta")) + .cloned() + .or_else(|| listing.map(|resource| resource.meta.clone())) + .unwrap_or_else(|| json!({})) +} + +fn parse_ui_resource( + response: &Value, + requested_uri: &str, + listing: Option<&McpAppResource>, +) -> Result<(String, McpAppResourceCsp, McpAppResourcePermissions), String> { + let contents = response + .pointer("/result/contents") + .and_then(Value::as_array) + .ok_or_else(|| "MCP resources/read response is missing result.contents".to_string())?; + if contents.len() != 1 { + return Err("MCP App resource must contain exactly one document".to_string()); + } + let content = &contents[0]; + if text(content.get("uri")).as_deref() != Some(requested_uri) { + return Err("MCP App resource URI does not match the request".to_string()); + } + if text(content.get("mimeType")).as_deref() != Some(MCP_APP_MIME_TYPE) { + return Err(format!("MCP App resource must use {MCP_APP_MIME_TYPE}")); + } + let html = if let Some(text) = content.get("text").and_then(Value::as_str) { + text.to_string() + } else if let Some(blob) = content.get("blob").and_then(Value::as_str) { + let bytes = base64::engine::general_purpose::STANDARD + .decode(blob) + .map_err(|_| "MCP App resource blob is not valid base64".to_string())?; + String::from_utf8(bytes) + .map_err(|_| "MCP App resource blob is not valid UTF-8".to_string())? + } else { + return Err("MCP App resource has no text or blob content".to_string()); + }; + if html.len() > MAX_MCP_APP_HTML_BYTES { + return Err("MCP App HTML exceeds the 4 MiB limit".to_string()); + } + let meta = resource_meta(content, listing); + let ui = meta.get("ui").cloned().unwrap_or_else(|| json!({})); + let csp = serde_json::from_value(ui.get("csp").cloned().unwrap_or_else(|| json!({}))) + .map_err(|error| format!("MCP App CSP metadata is invalid: {error}"))?; + let permissions = + serde_json::from_value(ui.get("permissions").cloned().unwrap_or_else(|| json!({}))) + .map_err(|error| format!("MCP App permission metadata is invalid: {error}"))?; + Ok((html, csp, permissions)) +} + +fn csp_origin(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(suffix) = raw.strip_prefix("https://*.") { + if !suffix.is_empty() + && !suffix.contains('/') + && suffix + .chars() + .all(|value| value.is_ascii_alphanumeric() || matches!(value, '.' | '-')) + { + return Some(raw.to_string()); + } + return None; + } + let url = Url::parse(raw).ok()?; + if !matches!(url.scheme(), "https" | "http") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return None; + } + Some(url.origin().ascii_serialization()) +} + +fn sources(values: &[String], fallback: &str) -> String { + let values = values.iter().filter_map(|value| csp_origin(value)); + let collected = values.collect::>(); + if collected.is_empty() { + fallback.to_string() + } else { + collected.join(" ") + } +} + +fn sandbox_csp(csp: &McpAppResourceCsp) -> String { + let resources = sources(&csp.resource_domains, ""); + let connects = sources(&csp.connect_domains, "'none'"); + let frames = sources(&csp.frame_domains, ""); + let bases = sources(&csp.base_uri_domains, "'none'"); + format!( + "default-src 'none'; script-src 'self' 'unsafe-inline' {resources}; \ + style-src 'self' 'unsafe-inline' {resources}; img-src 'self' data: blob: {resources}; \ + font-src 'self' data: {resources}; media-src 'self' data: blob: {resources}; \ + connect-src {connects}; frame-src 'self' {frames}; base-uri {bases}; \ + object-src 'none'; form-action 'none'" + ) +} + +fn app_tool_allowed(tool: &McpAppTool, caller: McpAppToolCaller) -> bool { + match caller { + McpAppToolCaller::Host => tool.visibility.iter().any(|value| value == "model"), + McpAppToolCaller::App => tool.visibility.iter().any(|value| value == "app"), + } +} + +/// Connect to a reviewed Streamable HTTP MCP server and discover its Apps. +#[tauri::command] +pub async fn connect_mcp_app_server( + endpoint: String, + state: State<'_, McpAppHostState>, +) -> Result { + let endpoint = validate_mcp_endpoint(&endpoint)?; + let client = build_pinned_client(&endpoint).await?; + let initialize = post_mcp( + &client, + &endpoint, + None, + None, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": [MCP_APP_MIME_TYPE] + } + } + }, + "clientInfo": { + "name": "Buzz Desktop", + "version": env!("CARGO_PKG_VERSION") + } + } + }), + ) + .await?; + let value = initialize + .value + .ok_or_else(|| "MCP initialize returned no response".to_string())?; + let protocol_version = text(value.pointer("/result/protocolVersion")) + .ok_or_else(|| "MCP initialize response is missing protocolVersion".to_string())?; + let server_name = + text(value.pointer("/result/serverInfo/name")).unwrap_or_else(|| endpoint.to_string()); + let server_version = text(value.pointer("/result/serverInfo/version")); + let connection = McpServerConnection { + endpoint: endpoint.clone(), + client, + protocol_version: protocol_version.clone(), + session_id: initialize.session_id, + next_request_id: Arc::new(AtomicU64::new(2)), + tools: Vec::new(), + resources: Vec::new(), + }; + let _ = post_mcp( + &connection.client, + &connection.endpoint, + Some(&connection.protocol_version), + connection.session_id.as_deref(), + &json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }), + ) + .await?; + let tools = parse_tools(&request(&connection, "tools/list", json!({})).await?)?; + let resources = request(&connection, "resources/list", json!({})) + .await + .and_then(|value| parse_resources(&value)) + .unwrap_or_default(); + let server_id = Uuid::new_v4().to_string(); + let connection = McpServerConnection { + tools: tools.clone(), + resources: resources.clone(), + ..connection + }; + let mut servers = state.servers.lock().await; + if servers.len() >= MAX_SERVERS { + return Err("Too many MCP App servers are connected".to_string()); + } + servers.insert(server_id.clone(), connection); + Ok(McpAppServerDescriptor { + server_id, + endpoint: endpoint.to_string(), + name: server_name, + version: server_version, + protocol_version, + tools, + resources, + }) +} + +/// List the reviewed tools for one connected MCP server. +#[tauri::command] +pub async fn list_mcp_app_tools( + server_id: String, + state: State<'_, McpAppHostState>, +) -> Result, String> { + state + .servers + .lock() + .await + .get(&server_id) + .map(|connection| connection.tools.clone()) + .ok_or_else(|| "MCP App server is not connected".to_string()) +} + +/// List the reviewed resources for one connected MCP server. +#[tauri::command] +pub async fn list_mcp_app_resources( + server_id: String, + state: State<'_, McpAppHostState>, +) -> Result, String> { + state + .servers + .lock() + .await + .get(&server_id) + .map(|connection| connection.resources.clone()) + .ok_or_else(|| "MCP App server is not connected".to_string()) +} + +/// Execute a reviewed MCP tool for the host or the isolated App. +#[tauri::command] +pub async fn call_mcp_app_tool( + server_id: String, + name: String, + arguments: Value, + caller: McpAppToolCaller, + state: State<'_, McpAppHostState>, +) -> Result { + let connection = state + .servers + .lock() + .await + .get(&server_id) + .cloned() + .ok_or_else(|| "MCP App server is not connected".to_string())?; + let tool = connection + .tools + .iter() + .find(|tool| tool.name == name) + .ok_or_else(|| "MCP App requested an unknown tool".to_string())?; + if !app_tool_allowed(tool, caller) { + return Err("MCP App tool is not visible to this caller".to_string()); + } + request( + &connection, + "tools/call", + json!({"name": name, "arguments": arguments}), + ) + .await + .and_then(|value| { + value + .get("result") + .cloned() + .ok_or_else(|| "MCP tools/call response is missing result".to_string()) + }) +} + +/// Read a resource for an initialized AppBridge request. +#[tauri::command] +pub async fn read_mcp_app_resource( + server_id: String, + uri: String, + state: State<'_, McpAppHostState>, +) -> Result { + let connection = state + .servers + .lock() + .await + .get(&server_id) + .cloned() + .ok_or_else(|| "MCP App server is not connected".to_string())?; + if !connection + .resources + .iter() + .any(|resource| resource.uri == uri) + { + return Err("MCP App requested an undiscovered resource".to_string()); + } + request(&connection, "resources/read", json!({"uri": uri})) + .await + .and_then(|value| { + value + .get("result") + .cloned() + .ok_or_else(|| "MCP resources/read response is missing result".to_string()) + }) +} + +/// Read and validate one UI resource, then register its CSP-bound sandbox URL. +#[tauri::command] +pub async fn prepare_mcp_app_view( + server_id: String, + uri: String, + state: State<'_, McpAppHostState>, +) -> Result { + let connection = state + .servers + .lock() + .await + .get(&server_id) + .cloned() + .ok_or_else(|| "MCP App server is not connected".to_string())?; + if !connection + .tools + .iter() + .any(|tool| tool.ui_resource_uri.as_deref() == Some(uri.as_str())) + { + return Err("MCP App resource is not declared by a reviewed tool".to_string()); + } + let response = request(&connection, "resources/read", json!({"uri": uri})).await?; + let listing = connection + .resources + .iter() + .find(|resource| resource.uri == uri); + let (html, csp, requested_permissions) = parse_ui_resource(&response, &uri, listing)?; + let view_id = Uuid::new_v4().to_string(); + let mut views = state + .views + .lock() + .map_err(|_| "MCP App view registry is unavailable".to_string())?; + if views.len() >= MAX_VIEWS { + return Err("Too many MCP App views are open".to_string()); + } + views.insert( + view_id.clone(), + ViewPolicy { + server_id, + csp: sandbox_csp(&csp), + }, + ); + Ok(PreparedMcpAppView { + sandbox_url: format!("buzz-mcp-app://localhost/{view_id}"), + view_id, + html, + csp, + requested_permissions, + }) +} + +/// Release an isolated MCP App view and its CSP policy. +#[tauri::command] +pub fn release_mcp_app_view( + view_id: String, + state: State<'_, McpAppHostState>, +) -> Result<(), String> { + state + .views + .lock() + .map_err(|_| "MCP App view registry is unavailable".to_string())? + .remove(&view_id); + Ok(()) +} + +/// Close an MCP server connection and release all views created from it. +#[tauri::command] +pub async fn disconnect_mcp_app_server( + server_id: String, + state: State<'_, McpAppHostState>, +) -> Result<(), String> { + let connection = state.servers.lock().await.remove(&server_id); + if let Some((connection, session_id)) = + connection.and_then(|connection| connection.session_id.clone().map(|id| (connection, id))) + { + let _ = connection + .client + .delete(connection.endpoint) + .header("mcp-protocol-version", connection.protocol_version) + .header("mcp-session-id", session_id) + .send() + .await; + } + state + .views + .lock() + .map_err(|_| "MCP App view registry is unavailable".to_string())? + .retain(|_, view| view.server_id != server_id); + Ok(()) +} + +fn html_response(status: u16, body: &str, csp: Option<&str>) -> http::Response> { + let mut builder = http::Response::builder() + .status(status) + .header("content-type", "text/html; charset=utf-8") + .header("cache-control", "no-store") + .header("x-content-type-options", "nosniff") + .header( + "permissions-policy", + "camera=(), microphone=(), geolocation=(), clipboard-write=()", + ); + if let Some(csp) = csp { + builder = builder.header("content-security-policy", csp); + } + builder + .body(body.as_bytes().to_vec()) + .unwrap_or_else(|_| http::Response::new(Vec::new())) +} + +/// Serve the trusted outer sandbox proxy from a Tauri-owned isolated origin. +pub fn handle_mcp_app_protocol( + app: &AppHandle, + request: &http::Request>, +) -> http::Response> { + let view_id = request.uri().path().trim_matches('/'); + if Uuid::parse_str(view_id).is_err() { + return html_response(404, "not found", None); + } + let state = app.state::(); + let views = match state.views.lock() { + Ok(views) => views, + Err(_) => return html_response(503, "unavailable", None), + }; + let Some(view) = views.get(view_id) else { + return html_response(404, "not found", None); + }; + html_response(200, SANDBOX_PROXY_HTML, Some(&view.csp)) +} + +#[cfg(test)] +#[path = "mcp_apps_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/mcp_apps_tests.rs b/desktop/src-tauri/src/commands/mcp_apps_tests.rs new file mode 100644 index 0000000000..9b53a504fe --- /dev/null +++ b/desktop/src-tauri/src/commands/mcp_apps_tests.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +fn extracts_nested_and_legacy_ui_resource_uris() { + assert_eq!( + ui_resource_uri(&json!({"ui": {"resourceUri": "ui://board"}})).as_deref(), + Some("ui://board") + ); + assert_eq!( + ui_resource_uri(&json!({"ui/resourceUri": "ui://legacy"})).as_deref(), + Some("ui://legacy") + ); + assert!(ui_resource_uri(&json!({"ui": {"resourceUri": "https://bad"}})).is_none()); +} + +#[test] +fn app_visibility_defaults_to_model_and_app() { + let default = tool_visibility(&json!({})); + assert_eq!(default, vec!["model", "app"]); + let model_only = tool_visibility(&json!({"ui": {"visibility": ["model"]}})); + assert_eq!(model_only, vec!["model"]); +} + +#[test] +fn caller_visibility_is_enforced() { + let tool = McpAppTool { + name: "private".to_string(), + title: None, + description: None, + input_schema: json!({}), + output_schema: None, + annotations: None, + meta: json!({}), + ui_resource_uri: None, + visibility: vec!["app".to_string()], + }; + assert!(app_tool_allowed(&tool, McpAppToolCaller::App)); + assert!(!app_tool_allowed(&tool, McpAppToolCaller::Host)); +} + +#[test] +fn endpoint_policy_allows_https_and_loopback_http() { + assert!(validate_mcp_endpoint("https://apps.example.com/mcp").is_ok()); + assert!(validate_mcp_endpoint("http://127.0.0.1:1337/mcp").is_ok()); + assert!(validate_mcp_endpoint("http://apps.example.com/mcp").is_err()); + assert!(validate_mcp_endpoint("https://user:secret@apps.example.com/mcp").is_err()); +} + +#[test] +fn rejects_private_and_transitional_network_addresses() { + for address in [ + "10.0.0.1", + "100.64.0.1", + "192.168.1.1", + "::ffff:127.0.0.1", + "::ffff:169.254.169.254", + "64:ff9b::7f00:1", + "2002:7f00:1::", + "2001::1", + "ff02::1", + ] { + assert!( + is_private_ip(address.parse().unwrap()), + "{address} must be rejected" + ); + } + assert!(!is_private_ip("2606:4700:4700::1111".parse().unwrap())); +} + +#[test] +fn csp_drops_invalid_sources_and_defaults_closed() { + let csp = sandbox_csp(&McpAppResourceCsp { + connect_domains: vec![ + "https://api.example.com".to_string(), + "https://example.com/path".to_string(), + ], + resource_domains: Vec::new(), + frame_domains: Vec::new(), + base_uri_domains: Vec::new(), + }); + assert!(csp.contains("connect-src https://api.example.com")); + assert!(!csp.contains("https://example.com/path")); + assert!(csp.contains("script-src 'self' 'unsafe-inline'")); + assert!(csp.contains("img-src 'self' data: blob:")); + assert!(csp.contains("frame-src 'self'")); + assert!(!csp.contains("frame-src 'self' buzz-mcp-app:")); + assert!(csp.contains("object-src 'none'")); +} + +#[test] +fn inner_app_frame_uses_an_opaque_origin_and_fixed_sandbox() { + assert!(SANDBOX_PROXY_HTML.contains(r#"inner.setAttribute("sandbox", "allow-scripts")"#)); + assert!(SANDBOX_PROXY_HTML.contains("inner.srcdoc = html")); + assert!(!SANDBOX_PROXY_HTML.contains("allow-same-origin allow-forms")); + assert!(!SANDBOX_PROXY_HTML.contains("inner.setAttribute(\"sandbox\", sandbox)")); +} + +#[test] +fn parses_text_ui_resource_and_metadata() { + let response = json!({ + "result": { + "contents": [{ + "uri": "ui://board", + "mimeType": MCP_APP_MIME_TYPE, + "text": "
Board
", + "_meta": { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"clipboardWrite": {}} + } + } + }] + } + }); + let (html, csp, permissions) = parse_ui_resource(&response, "ui://board", None).unwrap(); + assert_eq!(html, "
Board
"); + assert_eq!(csp.connect_domains, vec!["https://api.example.com"]); + assert!(permissions.clipboard_write.is_some()); +} + +#[test] +fn rejects_ui_resource_uri_mismatch() { + let response = json!({ + "result": { + "contents": [{ + "uri": "ui://other", + "mimeType": MCP_APP_MIME_TYPE, + "text": "
Other
" + }] + } + }); + assert!(parse_ui_resource(&response, "ui://board", None).is_err()); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..a932d73673 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ mod identity_archive; mod join_policy; mod legacy_storage; mod link_preview; +mod mcp_apps; pub(crate) mod media; mod media_animated; mod media_download; @@ -82,6 +83,7 @@ pub use identity_archive::*; pub use join_policy::*; pub use legacy_storage::*; pub use link_preview::*; +pub use mcp_apps::*; pub use media::*; pub use media_download::*; #[cfg(feature = "mesh-llm")] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5346791ccf..80c53d5529 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -350,7 +350,11 @@ pub fn run() { responder.respond(response); }); }) + .register_asynchronous_uri_scheme_protocol("buzz-mcp-app", |ctx, request, responder| { + responder.respond(handle_mcp_app_protocol(ctx.app_handle(), &request)); + }) .manage(build_app_state()) + .manage(McpAppHostState::default()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) .manage(BuilderlabSession::default()) @@ -714,6 +718,14 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + connect_mcp_app_server, + list_mcp_app_tools, + list_mcp_app_resources, + call_mcp_app_tool, + read_mcp_app_resource, + prepare_mcp_app_view, + release_mcp_app_view, + disconnect_mcp_app_server, fetch_link_preview_title, discover_acp_auth_methods, discover_acp_providers, diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 07b7216346..6eb589002f 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": null + "csp": "frame-src 'self' buzz-mcp-app:" } }, "plugins": { diff --git a/desktop/src/features/channels/lib/threadPanelLayout.test.mjs b/desktop/src/features/channels/lib/threadPanelLayout.test.mjs new file mode 100644 index 0000000000..f2d9ce682e --- /dev/null +++ b/desktop/src/features/channels/lib/threadPanelLayout.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getScreenLayout } from "./threadPanelLayout.ts"; + +test("uses single-panel layout for requested auxiliary content on narrow channels", () => { + assert.deepEqual( + getScreenLayout({ + appActive: false, + auxiliaryPanelRequested: true, + channelType: "stream", + contentWidthPx: 500, + }), + { + isSinglePanelView: true, + shouldCompactHeaderActions: true, + }, + ); +}); + +test("gives an active channel app the full content layout", () => { + assert.deepEqual( + getScreenLayout({ + appActive: true, + auxiliaryPanelRequested: true, + channelType: "stream", + contentWidthPx: 700, + }), + { + isSinglePanelView: false, + shouldCompactHeaderActions: false, + }, + ); +}); diff --git a/desktop/src/features/channels/lib/threadPanelLayout.ts b/desktop/src/features/channels/lib/threadPanelLayout.ts index d07aec0f35..9ff103e14c 100644 --- a/desktop/src/features/channels/lib/threadPanelLayout.ts +++ b/desktop/src/features/channels/lib/threadPanelLayout.ts @@ -1,6 +1,10 @@ import type * as React from "react"; import { THREAD_FOCUS_COLUMN_MAX_WIDTH_PX } from "@/features/channels/lib/threadFocusLayout"; +import type { ChannelType } from "@/shared/api/types"; +import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; + +const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760; export type ThreadPanelLayoutProps = { columnMaxWidthPx?: number; @@ -18,6 +22,33 @@ type ThreadPanelLayoutOptions = { useSplitAuxiliaryPane: boolean; }; +type ChannelScreenPanelLayoutOptions = { + appActive: boolean; + auxiliaryPanelRequested: boolean; + channelType?: ChannelType; + contentWidthPx: number; +}; + +export function getScreenLayout({ + appActive, + auxiliaryPanelRequested, + channelType, + contentWidthPx, +}: ChannelScreenPanelLayoutOptions) { + const hasAuxiliaryPanel = !appActive && auxiliaryPanelRequested; + const isNarrowPanelViewport = + contentWidthPx > 0 && + contentWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; + return { + isSinglePanelView: + isNarrowPanelViewport && channelType !== "forum" && hasAuxiliaryPanel, + shouldCompactHeaderActions: + hasAuxiliaryPanel && + contentWidthPx > 0 && + contentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX, + }; +} + /** Maps channel presentation into the shared thread-panel layout contract. */ export function getThreadPanelLayout({ headerLeading, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb7..ba5ebe01a0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,6 +7,7 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; +import { getScreenLayout } from "@/features/channels/lib/threadPanelLayout"; import { useChannelMembersQuery, useJoinChannelMutation, @@ -32,6 +33,7 @@ import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance"; import { useWelcomeKickoffStagePresence } from "@/features/onboarding/useWelcomeKickoffStagePresence"; import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate"; +import { useMcpAppUi } from "@/features/mcp-apps/lib/useChannelMcpAppExperience"; import { useCommunities } from "@/features/communities/useCommunities"; import { mergeMessages, @@ -71,7 +73,6 @@ import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayou import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; import { useElementWidth } from "@/shared/hooks/use-mobile"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; -import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useChannelActivityTyping } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; @@ -81,8 +82,7 @@ import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, - EMPTY_RELAY_EVENTS: RelayEvent[] = []; +const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, @@ -515,13 +515,16 @@ export function ChannelScreen({ threadReplyTargetId, toggleReactionMutation, }); - const effectiveToggleReaction = React.useMemo( - () => - activeChannel && !activeChannel.archivedAt && activeChannel.isMember - ? handleToggleReaction - : undefined, - [activeChannel, handleToggleReaction], + const channelApps = useMcpAppUi( + activeChannel, + currentPubkey, + handleSendMessage, ); + const canPostToChannel = + activeChannel?.isMember === true && !activeChannel.archivedAt; + const effectiveToggleReaction = canPostToChannel + ? handleToggleReaction + : undefined; const handleMessageMarkUnread = React.useCallback( (message: TimelineMessage) => handleMarkMessageUnread(message.id), [handleMarkMessageUnread], @@ -548,10 +551,9 @@ export function ChannelScreen({ }, [sendMessageMutateAsync], ); - const effectiveSendVideoReviewComment = - activeChannel && !activeChannel.archivedAt && activeChannel.isMember - ? handleSendVideoReviewComment - : undefined; + const effectiveSendVideoReviewComment = canPostToChannel + ? handleSendVideoReviewComment + : undefined; const handleOpenAddBot = React.useCallback( (options?: { beforeSend?: () => void }) => welcomeAgentCreate.openAddAgent(() => setIsAddBotOpen(true), options), @@ -684,12 +686,17 @@ export function ChannelScreen({ threadReplyTargetMessage, }); - const hasAuxiliaryPanel = Boolean( - effectiveOpenThreadHeadId || - openAgentSessionPubkey || - profilePanelPubkey || - channelManagementOpen, - ); + const { isSinglePanelView, shouldCompactHeaderActions } = getScreenLayout({ + appActive: channelApps.active, + auxiliaryPanelRequested: Boolean( + effectiveOpenThreadHeadId || + openAgentSessionPubkey || + profilePanelPubkey || + channelManagementOpen, + ), + channelType: activeChannel?.channelType, + contentWidthPx: channelContentWidthPx, + }); const displayedThreadHeadMessage = threadPanelData.threadHead; const displayedThreadMessages = threadPanelData.visibleReplies; const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; @@ -699,17 +706,6 @@ export function ChannelScreen({ const shouldShowThreadSkeleton = Boolean( effectiveOpenThreadHeadId && activeChannel && !displayedThreadHeadMessage, ); - const isNarrowPanelViewport = - channelContentWidthPx > 0 && - channelContentWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; - const isSinglePanelView = - isNarrowPanelViewport && - activeChannel?.channelType !== "forum" && - hasAuxiliaryPanel; - const shouldCompactHeaderActions = - hasAuxiliaryPanel && - channelContentWidthPx > 0 && - channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX; const channelHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, ...channelContentTopPaddingMeasurement, @@ -763,6 +759,7 @@ export function ChannelScreen({ currentPubkey={currentPubkey} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} + navigation={channelApps.navigation} onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} onManageChannel={handleManageChannel} @@ -782,6 +779,7 @@ export function ChannelScreen({ channelHeaderChromeRef, currentPubkey, isAddBotOpen, + channelApps.navigation, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, handleManageChannel, @@ -828,6 +826,8 @@ export function ChannelScreen({ selectedPostId={selectedForumPostId} targetReplyId={targetForumReplyId} /> + ) : channelApps.active ? ( + channelApps.renderPane(channelHeader) ) : ( } diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a20231..2e665a9550 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -41,6 +41,7 @@ type ChannelScreenHeaderProps = { onJoinChannel?: () => Promise; onManageChannel: () => void; onToggleMembers: () => void; + navigation?: React.ReactNode; }; export function ChannelScreenHeader({ @@ -61,6 +62,7 @@ export function ChannelScreenHeader({ onJoinChannel, onManageChannel, onToggleMembers, + navigation, }: ChannelScreenHeaderProps) { const isGroupDm = activeChannel?.channelType === "dm" && @@ -129,6 +131,7 @@ export function ChannelScreenHeader({ ) ) : undefined } + navigation={navigation} statusBadge={ + {navigation ?
{navigation}
: null} +
{actions ?
{actions}
: null} diff --git a/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.test.mjs b/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.test.mjs new file mode 100644 index 0000000000..0501cd7427 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseChannelMcpAppStore } from "./channelMcpAppStorage.ts"; + +test("parses only complete MCP App installations", () => { + assert.deepEqual( + parseChannelMcpAppStore({ + version: 1, + channels: { + channel: [ + { + id: "board", + endpoint: "https://runtime.example/mcp", + serverName: "Runtime", + toolName: "board.open", + title: "Board", + resourceUri: "ui://runtime/board", + arguments: { space: "alpha" }, + }, + { + id: "bad", + endpoint: "https://runtime.example/mcp", + toolName: "bad.open", + resourceUri: "https://runtime.example/app", + }, + ], + }, + }), + { + version: 1, + channels: { + channel: [ + { + id: "board", + endpoint: "https://runtime.example/mcp", + serverName: "Runtime", + toolName: "board.open", + title: "Board", + resourceUri: "ui://runtime/board", + arguments: { space: "alpha" }, + }, + ], + }, + }, + ); +}); + +test("rejects unknown store versions", () => { + assert.equal(parseChannelMcpAppStore({ version: 2, channels: {} }), null); +}); diff --git a/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.ts b/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.ts new file mode 100644 index 0000000000..8d30e87387 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/channelMcpAppStorage.ts @@ -0,0 +1,186 @@ +const STORAGE_KEY_PREFIX = "buzz-channel-mcp-apps.v1"; + +export const CHANNEL_MCP_APPS_CHANGE_EVENT = "buzz:channel-mcp-apps-change"; + +export type ChannelMcpAppInstallation = { + id: string; + endpoint: string; + serverName: string; + toolName: string; + title: string; + resourceUri: string; + arguments: Record; +}; + +type ChannelMcpAppStore = { + version: 1; + channels: Record; +}; + +const EMPTY_STORE: ChannelMcpAppStore = { + version: 1, + channels: {}, +}; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +function normalizeInstallation( + value: unknown, +): ChannelMcpAppInstallation | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const source = value as Record; + const id = typeof source.id === "string" ? source.id.trim() : ""; + const endpoint = + typeof source.endpoint === "string" ? source.endpoint.trim() : ""; + const toolName = + typeof source.toolName === "string" ? source.toolName.trim() : ""; + const resourceUri = + typeof source.resourceUri === "string" ? source.resourceUri.trim() : ""; + if (!id || !endpoint || !toolName || !resourceUri.startsWith("ui://")) { + return null; + } + const argumentsValue = + source.arguments && + typeof source.arguments === "object" && + !Array.isArray(source.arguments) + ? (source.arguments as Record) + : {}; + return { + id, + endpoint, + serverName: + typeof source.serverName === "string" && source.serverName.trim() + ? source.serverName.trim() + : endpoint, + toolName, + title: + typeof source.title === "string" && source.title.trim() + ? source.title.trim() + : toolName, + resourceUri, + arguments: argumentsValue, + }; +} + +export function parseChannelMcpAppStore( + value: unknown, +): ChannelMcpAppStore | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const source = value as Record; + if ( + source.version !== 1 || + !source.channels || + typeof source.channels !== "object" || + Array.isArray(source.channels) + ) { + return null; + } + const channels = Object.fromEntries( + Object.entries(source.channels as Record).flatMap( + ([channelId, installations]) => { + if (!Array.isArray(installations)) return []; + const normalized = installations + .map(normalizeInstallation) + .filter( + (installation): installation is ChannelMcpAppInstallation => + installation !== null, + ); + return normalized.length > 0 ? [[channelId, normalized]] : []; + }, + ), + ); + return { version: 1, channels }; +} + +function readStore(pubkey: string): ChannelMcpAppStore { + if (typeof window === "undefined") return EMPTY_STORE; + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) return EMPTY_STORE; + return parseChannelMcpAppStore(JSON.parse(raw)) ?? EMPTY_STORE; + } catch { + return EMPTY_STORE; + } +} + +function notify(): void { + if ( + typeof window !== "undefined" && + typeof window.dispatchEvent === "function" + ) { + window.dispatchEvent(new CustomEvent(CHANNEL_MCP_APPS_CHANGE_EVENT)); + } +} + +function writeStore(pubkey: string, store: ChannelMcpAppStore): boolean { + if (typeof window === "undefined") return false; + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + notify(); + return true; + } catch { + return false; + } +} + +export function subscribeChannelMcpApps(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const onChange = () => listener(); + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key.startsWith(STORAGE_KEY_PREFIX)) { + listener(); + } + }; + window.addEventListener(CHANNEL_MCP_APPS_CHANGE_EVENT, onChange); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(CHANNEL_MCP_APPS_CHANGE_EVENT, onChange); + window.removeEventListener("storage", onStorage); + }; +} + +export function getChannelMcpApps( + pubkey: string, + channelId: string, +): ChannelMcpAppInstallation[] { + return [...(readStore(pubkey).channels[channelId] ?? [])]; +} + +export function installChannelMcpApp( + pubkey: string, + channelId: string, + installation: ChannelMcpAppInstallation, +): boolean { + const store = readStore(pubkey); + const current = store.channels[channelId] ?? []; + const next = [ + ...current.filter((candidate) => candidate.id !== installation.id), + installation, + ]; + return writeStore(pubkey, { + version: 1, + channels: { ...store.channels, [channelId]: next }, + }); +} + +export function removeChannelMcpApp( + pubkey: string, + channelId: string, + installationId: string, +): boolean { + const store = readStore(pubkey); + const current = store.channels[channelId] ?? []; + const next = current.filter( + (installation) => installation.id !== installationId, + ); + if (next.length === current.length) return true; + const channels = { ...store.channels }; + if (next.length > 0) { + channels[channelId] = next; + } else { + delete channels[channelId]; + } + return writeStore(pubkey, { version: 1, channels }); +} diff --git a/desktop/src/features/mcp-apps/lib/mcpAppBridge.test.mjs b/desktop/src/features/mcp-apps/lib/mcpAppBridge.test.mjs new file mode 100644 index 0000000000..9416cdc623 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/mcpAppBridge.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + defaultMcpAppHostContext, + mcpAppSandboxOrigin, +} from "./mcpAppBridge.ts"; + +test("derives an exact origin for the custom sandbox protocol", () => { + assert.equal( + mcpAppSandboxOrigin("buzz-mcp-app://localhost/7f6d"), + "buzz-mcp-app://localhost", + ); +}); + +test("default host context identifies Buzz as a desktop host", () => { + const originalDocument = globalThis.document; + const originalNavigator = globalThis.navigator; + const originalWindow = globalThis.window; + globalThis.document = { + documentElement: { classList: { contains: () => true } }, + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { language: "en-US", maxTouchPoints: 0 }, + }); + globalThis.window = { + matchMedia: () => ({ matches: true }), + }; + + const context = defaultMcpAppHostContext(); + assert.equal(context.platform, "desktop"); + assert.equal(context.theme, "dark"); + assert.equal(context.locale, "en-US"); + assert.deepEqual(context.availableDisplayModes, ["inline", "fullscreen"]); + + globalThis.document = originalDocument; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: originalNavigator, + }); + globalThis.window = originalWindow; +}); diff --git a/desktop/src/features/mcp-apps/lib/mcpAppBridge.ts b/desktop/src/features/mcp-apps/lib/mcpAppBridge.ts new file mode 100644 index 0000000000..4d50866fe5 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/mcpAppBridge.ts @@ -0,0 +1,257 @@ +import { + AppBridge, + type McpUiHostContext, + type McpUiResourceCsp, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { + CallToolResult, + JSONRPCMessage, + ListResourcesResult, + MessageExtraInfo, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { + callMcpAppTool, + listMcpAppResources, + readMcpAppResource, + type McpAppResource, +} from "@/shared/api/tauriMcpApps"; + +const HOST_INFO = { name: "Buzz Desktop", version: "1.0.0" }; + +export function mcpAppSandboxOrigin(sandboxUrl: string): string { + const url = new URL(sandboxUrl); + if (url.origin !== "null") return url.origin; + if (!url.host) throw new Error("MCP App sandbox URL has no origin"); + return `${url.protocol}//${url.host}`; +} + +class OriginValidatedPostMessageTransport implements Transport { + private readonly eventSource: MessageEventSource; + private readonly eventTarget: Window; + private readonly expectedOrigin: string; + private readonly messageListener: (event: MessageEvent) => void; + + constructor( + eventTarget: Window, + eventSource: MessageEventSource, + expectedOrigin: string, + ) { + this.eventTarget = eventTarget; + this.eventSource = eventSource; + this.expectedOrigin = expectedOrigin; + this.messageListener = (event) => { + if ( + event.source !== this.eventSource || + event.origin !== this.expectedOrigin + ) { + return; + } + const parsed = JSONRPCMessageSchema.safeParse(event.data); + if (parsed.success) { + this.onmessage?.(parsed.data); + } else if (event.data?.jsonrpc === "2.0") { + this.onerror?.( + new Error(`Invalid MCP App message: ${parsed.error.message}`), + ); + } + }; + } + + async start(): Promise { + window.addEventListener("message", this.messageListener); + } + + async send( + message: JSONRPCMessage, + _options?: TransportSendOptions, + ): Promise { + this.eventTarget.postMessage(message, this.expectedOrigin); + } + + async close(): Promise { + window.removeEventListener("message", this.messageListener); + this.onclose?.(); + } + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; +} + +export type McpAppMessage = { + role: "user"; + content: unknown; +}; + +export type McpAppModelContext = { + content?: unknown[]; + structuredContent?: Record; +}; + +export type McpAppBridgeCallbacks = { + onMessage?: (message: McpAppMessage) => Promise | void; + onModelContext?: (context: McpAppModelContext | null) => Promise | void; + onOpenLink?: (url: string) => Promise | boolean; + onDisplayMode?: (mode: "inline" | "fullscreen") => void; + onSizeChange?: (size: { width?: number; height?: number }) => void; +}; + +function bridgeResource(resource: McpAppResource) { + return { + uri: resource.uri, + name: resource.name ?? resource.uri, + title: resource.title ?? undefined, + description: resource.description ?? undefined, + mimeType: resource.mimeType ?? undefined, + _meta: resource.meta, + }; +} + +export function defaultMcpAppHostContext(): McpUiHostContext { + return { + theme: document.documentElement.classList.contains("dark") + ? "dark" + : "light", + platform: "desktop", + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + displayMode: "inline", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { maxHeight: 6000 }, + deviceCapabilities: { + touch: navigator.maxTouchPoints > 0, + hover: window.matchMedia("(hover: hover)").matches, + }, + }; +} + +export function createMcpAppBridge( + serverId: string, + callbacks: McpAppBridgeCallbacks, +): AppBridge { + const bridge = new AppBridge( + null, + HOST_INFO, + { + serverTools: {}, + serverResources: {}, + ...(callbacks.onMessage ? { message: { text: {} } } : {}), + ...(callbacks.onModelContext + ? { updateModelContext: { text: {}, structuredContent: {} } } + : {}), + ...(callbacks.onOpenLink ? { openLinks: {} } : {}), + }, + { hostContext: defaultMcpAppHostContext() }, + ); + + bridge.oncalltool = async ({ name, arguments: args }) => + (await callMcpAppTool( + serverId, + name, + (args ?? {}) as Record, + "app", + )) as CallToolResult; + bridge.onlistresources = async () => + ({ + resources: (await listMcpAppResources(serverId)).map(bridgeResource), + }) as ListResourcesResult; + bridge.onreadresource = async ({ uri }) => + (await readMcpAppResource(serverId, uri)) as ReadResourceResult; + bridge.onmessage = async (message) => { + try { + await callbacks.onMessage?.(message as McpAppMessage); + return {}; + } catch { + return { isError: true }; + } + }; + bridge.onupdatemodelcontext = async (context) => { + const hasContent = Boolean(context.content?.length); + const hasStructured = Boolean( + context.structuredContent && + Object.keys(context.structuredContent).length > 0, + ); + await callbacks.onModelContext?.( + hasContent || hasStructured ? (context as McpAppModelContext) : null, + ); + return {}; + }; + if (callbacks.onOpenLink) { + bridge.onopenlink = async ({ url }) => ({ + isError: !(await callbacks.onOpenLink?.(url)), + }); + } + bridge.onrequestdisplaymode = async ({ mode }) => { + const next = mode === "fullscreen" ? "fullscreen" : "inline"; + callbacks.onDisplayMode?.(next); + await bridge.sendHostContextChange({ displayMode: next }); + return { mode: next }; + }; + bridge.onsizechange = callbacks.onSizeChange; + + return bridge; +} + +export async function connectMcpAppBridge( + bridge: AppBridge, + iframe: HTMLIFrameElement, + expectedOrigin: string, +): Promise { + const targetWindow = iframe.contentWindow; + if (!targetWindow) { + throw new Error("MCP App sandbox browsing context is unavailable"); + } + await bridge.connect( + new OriginValidatedPostMessageTransport( + targetWindow, + targetWindow, + expectedOrigin, + ), + ); +} + +export function observeMcpAppHostContext( + bridge: AppBridge, + iframe: HTMLIFrameElement, +): () => void { + const sendDimensions = () => { + const width = Math.round(iframe.getBoundingClientRect().width); + if (width > 0) { + void bridge.sendHostContextChange({ + containerDimensions: { width, maxHeight: 6000 }, + }); + } + }; + const resizeObserver = new ResizeObserver(sendDimensions); + resizeObserver.observe(iframe); + + const themeObserver = new MutationObserver(() => { + void bridge.sendHostContextChange({ + theme: document.documentElement.classList.contains("dark") + ? "dark" + : "light", + }); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + sendDimensions(); + + return () => { + resizeObserver.disconnect(); + themeObserver.disconnect(); + }; +} + +export type PreparedBridgeResource = { + html: string; + csp: McpUiResourceCsp; +}; diff --git a/desktop/src/features/mcp-apps/lib/mcpAppMessage.test.mjs b/desktop/src/features/mcp-apps/lib/mcpAppMessage.test.mjs new file mode 100644 index 0000000000..60bccf5a76 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/mcpAppMessage.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MCP_APP_POST_MAX_CHARS, mcpAppMessageText } from "./mcpAppMessage.ts"; + +test("extracts text from standard MCP content blocks", () => { + assert.equal( + mcpAppMessageText({ + role: "user", + content: [ + { type: "text", text: "Create the task." }, + { type: "image", data: "ignored" }, + { type: "text", text: "Keep it in this thread." }, + ], + }), + "Create the task.\n\nKeep it in this thread.", + ); +}); + +test("accepts the legacy single text block used by existing apps", () => { + assert.equal( + mcpAppMessageText({ + role: "user", + content: { type: "text", text: "Move this to review." }, + }), + "Move this to review.", + ); +}); + +test("rejects messages without text", () => { + assert.equal( + mcpAppMessageText({ + role: "user", + content: [{ type: "image", data: "ignored" }], + }), + null, + ); +}); + +test("collapses excessive blank lines without flattening paragraphs", () => { + assert.equal( + mcpAppMessageText({ + role: "user", + content: "First paragraph.\n\n\n\n\nSecond paragraph.", + }), + "First paragraph.\n\nSecond paragraph.", + ); + assert.equal(MCP_APP_POST_MAX_CHARS, 8_000); +}); diff --git a/desktop/src/features/mcp-apps/lib/mcpAppMessage.ts b/desktop/src/features/mcp-apps/lib/mcpAppMessage.ts new file mode 100644 index 0000000000..32d4e0c488 --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/mcpAppMessage.ts @@ -0,0 +1,31 @@ +import type { McpAppMessage } from "@/features/mcp-apps/lib/mcpAppBridge"; + +export const MCP_APP_POST_MAX_CHARS = 8_000; + +function normalizeText(value: string): string { + return value.trim().replace(/\n(?:[ \t]*\n){2,}/g, "\n\n"); +} + +export function mcpAppMessageText(message: McpAppMessage): string | null { + const blocks = Array.isArray(message.content) + ? message.content + : [message.content]; + const text = blocks + .flatMap((block) => { + if (typeof block === "string") return [block]; + if ( + block && + typeof block === "object" && + !Array.isArray(block) && + (block as Record).type === "text" && + typeof (block as Record).text === "string" + ) { + return [(block as Record).text as string]; + } + return []; + }) + .map(normalizeText) + .filter(Boolean) + .join("\n\n"); + return text || null; +} diff --git a/desktop/src/features/mcp-apps/lib/useChannelMcpAppExperience.tsx b/desktop/src/features/mcp-apps/lib/useChannelMcpAppExperience.tsx new file mode 100644 index 0000000000..c396fde06a --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/useChannelMcpAppExperience.tsx @@ -0,0 +1,257 @@ +import * as React from "react"; + +import type { ChannelMcpAppInstallation } from "@/features/mcp-apps/lib/channelMcpAppStorage"; +import { + MCP_APP_POST_MAX_CHARS, + mcpAppMessageText, +} from "@/features/mcp-apps/lib/mcpAppMessage"; +import { useChannelMcpApps } from "@/features/mcp-apps/lib/useChannelMcpApps"; +import { ChannelMcpAppDialog } from "@/features/mcp-apps/ui/ChannelMcpAppDialog"; +import { ChannelMcpAppPane } from "@/features/mcp-apps/ui/ChannelMcpAppPane"; +import { ChannelMcpAppTabs } from "@/features/mcp-apps/ui/ChannelMcpAppTabs"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type SendChannelMessage = ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, +) => Promise; + +type PendingChannelPost = { + appKey: string; + appTitle: string; + channelId: string; + content: string; + reject: (error: Error) => void; + resolve: () => void; +}; + +const MCP_APP_POST_PROMPT_COOLDOWN_MS = 30_000; + +export function useMcpAppUi( + channel: Channel | null, + pubkey: string | null | undefined, + sendMessage: SendChannelMessage, +) { + const [dialogOpen, setDialogOpen] = React.useState(false); + const [pendingPost, setPendingPost] = + React.useState(null); + const pendingPostRef = React.useRef(null); + const promptedAtRef = React.useRef(new Map()); + const [mutedAppKeys, setMutedAppKeys] = React.useState>( + () => new Set(), + ); + const [isPosting, setIsPosting] = React.useState(false); + const [postError, setPostError] = React.useState(null); + const apps = useChannelMcpApps({ + channelId: channel?.id ?? null, + pubkey, + }); + const rejectPendingPost = React.useCallback((reason: string) => { + const current = pendingPostRef.current; + pendingPostRef.current = null; + setPendingPost(null); + setPostError(null); + current?.reject(new Error(reason)); + }, []); + React.useEffect(() => { + if ( + pendingPostRef.current && + pendingPostRef.current.channelId !== channel?.id + ) { + rejectPendingPost( + "The channel changed before the app post was approved.", + ); + } + }, [channel?.id, rejectPendingPost]); + React.useEffect( + () => () => + rejectPendingPost("The channel app closed before the post was approved."), + [rejectPendingPost], + ); + const handleMessage = React.useCallback( + async ( + app: ChannelMcpAppInstallation, + message: Parameters[0], + ) => { + if (!channel?.isMember || channel.archivedAt || !channel.id) { + throw new Error("This channel is read-only."); + } + const appKey = `${channel.id}:${app.id}`; + if (mutedAppKeys.has(appKey)) { + throw new Error("Channel post requests from this app are muted."); + } + const now = Date.now(); + const promptedAt = promptedAtRef.current.get(appKey) ?? 0; + if (now - promptedAt < MCP_APP_POST_PROMPT_COOLDOWN_MS) { + throw new Error("This app requested another channel post too quickly."); + } + const content = mcpAppMessageText(message); + if (!content) { + throw new Error("The app message did not contain text."); + } + if (content.length > MCP_APP_POST_MAX_CHARS) { + throw new Error( + `The app message exceeds the ${MCP_APP_POST_MAX_CHARS.toLocaleString()} character limit.`, + ); + } + if (pendingPostRef.current) { + throw new Error("Another app post is waiting for approval."); + } + promptedAtRef.current.set(appKey, now); + await new Promise((resolve, reject) => { + const next = { + appKey, + appTitle: app.title, + channelId: channel.id, + content, + reject, + resolve, + }; + pendingPostRef.current = next; + setPostError(null); + setPendingPost(next); + }); + }, + [channel?.archivedAt, channel?.id, channel?.isMember, mutedAppKeys], + ); + const handleMuteAppPosts = React.useCallback(() => { + const current = pendingPostRef.current; + if (!current) return; + setMutedAppKeys((muted) => new Set(muted).add(current.appKey)); + rejectPendingPost("Channel post requests from this app are muted."); + }, [rejectPendingPost]); + const handleApprovePost = React.useCallback(async () => { + const current = pendingPostRef.current; + if (!current || !channel) return; + setIsPosting(true); + setPostError(null); + try { + await sendMessage(current.content, [], undefined, channel.id); + if (pendingPostRef.current === current) { + pendingPostRef.current = null; + setPendingPost(null); + current.resolve(); + } + } catch (cause) { + setPostError( + cause instanceof Error + ? cause.message + : "Buzz could not post the app message.", + ); + } finally { + setIsPosting(false); + } + }, [channel, sendMessage]); + const dialog = + channel && pubkey ? ( + + ) : null; + const postApprovalDialog = channel ? ( + { + if (!open && !isPosting) { + rejectPendingPost("The channel post was not approved."); + } + }} + open={pendingPost !== null} + > + + + Post from {pendingPost?.appTitle ?? "app"}? + + Review this app request before it appears in #{channel.name}. + + +
+          {pendingPost?.content}
+        
+ {postError ? ( +

+ {postError} +

+ ) : null} + + + + + +
+
+ ) : null; + const navigation = + channel?.channelType !== "forum" && + channel?.isMember && + !channel.archivedAt ? ( + <> + setDialogOpen(true)} + onShowChat={apps.showChat} + /> + {dialog} + {postApprovalDialog} + + ) : undefined; + const renderPane = React.useCallback( + (header: React.ReactNode) => + apps.activeApp ? ( + + ) : null, + [apps.activeApp, handleMessage], + ); + + return { + active: apps.activeApp !== null, + navigation, + renderPane, + }; +} diff --git a/desktop/src/features/mcp-apps/lib/useChannelMcpApps.ts b/desktop/src/features/mcp-apps/lib/useChannelMcpApps.ts new file mode 100644 index 0000000000..2e3926d48c --- /dev/null +++ b/desktop/src/features/mcp-apps/lib/useChannelMcpApps.ts @@ -0,0 +1,52 @@ +import * as React from "react"; + +import { + getChannelMcpApps, + subscribeChannelMcpApps, + type ChannelMcpAppInstallation, +} from "@/features/mcp-apps/lib/channelMcpAppStorage"; + +export function useChannelMcpApps({ + channelId, + pubkey, +}: { + channelId: string | null; + pubkey: string | null | undefined; +}) { + const getSnapshot = React.useCallback(() => { + if (!channelId || !pubkey) return "[]"; + return JSON.stringify(getChannelMcpApps(pubkey, channelId)); + }, [channelId, pubkey]); + const serialized = React.useSyncExternalStore( + subscribeChannelMcpApps, + getSnapshot, + getSnapshot, + ); + const apps = React.useMemo( + () => JSON.parse(serialized) as ChannelMcpAppInstallation[], + [serialized], + ); + const [selection, setSelection] = React.useState<{ + channelId: string | null; + appId: string | null; + }>({ channelId, appId: null }); + const selectedAppId = + selection.channelId === channelId ? selection.appId : null; + const activeApp = + apps.find((installation) => installation.id === selectedAppId) ?? null; + const activeAppId = activeApp?.id ?? null; + + return { + apps, + activeApp, + activeAppId, + activateApp: React.useCallback( + (appId: string) => setSelection({ channelId, appId }), + [channelId], + ), + showChat: React.useCallback( + () => setSelection({ channelId, appId: null }), + [channelId], + ), + }; +} diff --git a/desktop/src/features/mcp-apps/ui/ChannelMcpAppDialog.tsx b/desktop/src/features/mcp-apps/ui/ChannelMcpAppDialog.tsx new file mode 100644 index 0000000000..7dd2aa14d5 --- /dev/null +++ b/desktop/src/features/mcp-apps/ui/ChannelMcpAppDialog.tsx @@ -0,0 +1,393 @@ +import { AppWindow, LoaderCircle, Plus, Trash2 } from "lucide-react"; +import * as React from "react"; + +import { + installChannelMcpApp, + removeChannelMcpApp, + type ChannelMcpAppInstallation, +} from "@/features/mcp-apps/lib/channelMcpAppStorage"; +import { + connectMcpAppServer, + disconnectMcpAppServer, + type McpAppServerDescriptor, + type McpAppTool, +} from "@/shared/api/tauriMcpApps"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +type ChannelMcpAppDialogProps = { + apps: ChannelMcpAppInstallation[]; + channelId: string; + open: boolean; + pubkey: string; + onOpenChange: (open: boolean) => void; +}; + +function installationId(endpoint: string, toolName: string): string { + return `${encodeURIComponent(endpoint)}:${toolName}`; +} + +function initialArguments(tool: McpAppTool): Record { + const required = Array.isArray(tool.inputSchema.required) + ? tool.inputSchema.required.filter( + (name): name is string => typeof name === "string", + ) + : []; + const properties = + tool.inputSchema.properties && + typeof tool.inputSchema.properties === "object" && + !Array.isArray(tool.inputSchema.properties) + ? (tool.inputSchema.properties as Record) + : {}; + return Object.fromEntries( + required.map((name) => { + const property = properties[name]; + const type = + property && typeof property === "object" && !Array.isArray(property) + ? (property as Record).type + : undefined; + return [name, type === "array" ? [] : type === "boolean" ? false : ""]; + }), + ); +} + +function parseArguments(value: string): Record | null { + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function advertisedDomains( + server: McpAppServerDescriptor | null, + tool: McpAppTool | null, +): string[] { + if (!server || !tool?.uiResourceUri) return []; + const resource = server.resources.find( + (candidate) => candidate.uri === tool.uiResourceUri, + ); + const ui = + resource?.meta.ui && + typeof resource.meta.ui === "object" && + !Array.isArray(resource.meta.ui) + ? (resource.meta.ui as Record) + : null; + const csp = + ui?.csp && typeof ui.csp === "object" && !Array.isArray(ui.csp) + ? (ui.csp as Record) + : null; + return [ + csp?.connectDomains, + csp?.resourceDomains, + csp?.frameDomains, + csp?.baseUriDomains, + ] + .flatMap((value) => (Array.isArray(value) ? value : [])) + .filter((value): value is string => typeof value === "string") + .filter((value, index, values) => values.indexOf(value) === index); +} + +export function ChannelMcpAppDialog({ + apps, + channelId, + open, + pubkey, + onOpenChange, +}: ChannelMcpAppDialogProps) { + const [endpoint, setEndpoint] = React.useState(""); + const [server, setServer] = React.useState( + null, + ); + const [selectedTool, setSelectedTool] = React.useState( + null, + ); + const [argumentsJson, setArgumentsJson] = React.useState("{}"); + const [isConnecting, setIsConnecting] = React.useState(false); + const [error, setError] = React.useState(null); + const serverRef = React.useRef(null); + const connectionAttemptRef = React.useRef(0); + + const uiTools = React.useMemo( + () => server?.tools.filter((tool) => tool.uiResourceUri) ?? [], + [server], + ); + const parsedArguments = React.useMemo( + () => parseArguments(argumentsJson), + [argumentsJson], + ); + const selectedDomains = React.useMemo( + () => advertisedDomains(server, selectedTool), + [selectedTool, server], + ); + + const clearConnectedServer = React.useCallback(() => { + const current = serverRef.current; + serverRef.current = null; + setServer(null); + setSelectedTool(null); + if (current) void disconnectMcpAppServer(current.serverId); + }, []); + + React.useEffect( + () => () => { + connectionAttemptRef.current += 1; + const current = serverRef.current; + serverRef.current = null; + if (current) void disconnectMcpAppServer(current.serverId); + }, + [], + ); + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) { + connectionAttemptRef.current += 1; + clearConnectedServer(); + setError(null); + setIsConnecting(false); + } + onOpenChange(nextOpen); + } + + async function handleConnect() { + const nextEndpoint = endpoint.trim(); + if (!nextEndpoint) return; + const attempt = connectionAttemptRef.current + 1; + connectionAttemptRef.current = attempt; + clearConnectedServer(); + setIsConnecting(true); + setError(null); + try { + const next = await connectMcpAppServer(nextEndpoint); + if (connectionAttemptRef.current !== attempt) { + await disconnectMcpAppServer(next.serverId); + return; + } + const tools = next.tools.filter((tool) => tool.uiResourceUri); + serverRef.current = next; + setServer(next); + setSelectedTool(tools[0] ?? null); + setArgumentsJson( + JSON.stringify(tools[0] ? initialArguments(tools[0]) : {}, null, 2), + ); + if (tools.length === 0) { + setError("This server did not advertise any MCP Apps."); + } + } catch (cause) { + if (connectionAttemptRef.current !== attempt) return; + clearConnectedServer(); + setError( + cause instanceof Error + ? cause.message + : "Buzz could not connect to this MCP server.", + ); + } finally { + if (connectionAttemptRef.current === attempt) setIsConnecting(false); + } + } + + function handleSelectTool(tool: McpAppTool) { + setSelectedTool(tool); + setArgumentsJson(JSON.stringify(initialArguments(tool), null, 2)); + setError(null); + } + + function handleInstall() { + if (!server || !selectedTool?.uiResourceUri || !parsedArguments) return; + const installed = installChannelMcpApp(pubkey, channelId, { + id: installationId(server.endpoint, selectedTool.name), + endpoint: server.endpoint, + serverName: server.name, + toolName: selectedTool.name, + title: selectedTool.title || selectedTool.name, + resourceUri: selectedTool.uiResourceUri, + arguments: parsedArguments, + }); + if (!installed) { + setError("Buzz could not save this channel app."); + return; + } + handleOpenChange(false); + } + + return ( + + + + Channel apps + + Add an MCP App as a tab beside this channel’s conversation. + + + + {apps.length > 0 ? ( +
+

+ Installed +

+
+ {apps.map((app) => ( +
+ + + + {app.title} + + + {app.serverName} + + + +
+ ))} +
+
+ ) : null} + +
+

+ Connect a server +

+
+ setEndpoint(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleConnect(); + }} + placeholder="https://runtime.example.com/mcp" + value={endpoint} + /> + +
+ + {uiTools.length > 0 ? ( +
+ {uiTools.map((tool) => ( + + ))} +
+ ) : null} + + {selectedTool ? ( + <> +
+

+ Advertised external domains +

+ {selectedDomains.length > 0 ? ( +
    + {selectedDomains.map((domain) => ( +
  • + {domain} +
  • + ))} +
+ ) : ( +

+ None declared in the discovery metadata. +

+ )} +
+