diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index cf5593e..4773bf4 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -55,6 +55,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Gateway public origin, Cloudflare tunnel identity, and container network isolation state - Native Whisper model files loaded by the gateway process for audio transcription - Experimental Plugin MCP Container config at `/brain3.yaml`, referenced container images, referenced bearer-token secret files, and host directories mounted into those containers +- Static MCP widget assets exposed by opted-in Plugin MCP Containers under gateway-managed per-container mount paths ### Trust Boundaries @@ -64,6 +65,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local process-control principals that can signal the running gateway process - The boundary between the Rust gateway and the `brain3-mcp-vault-tools` container - The boundary between the Rust gateway and any opt-in Plugin MCP Containers started from `/brain3.yaml` +- The unauthenticated static-asset path from the public gateway origin to a configured Plugin MCP Container's `mcp-use/**` widget assets - The native transcription boundary where the Rust gateway downloads and parses untrusted audio bytes in-process instead of forwarding them to the container - Gateway-initiated internet egress for temporary audio `download_url` fetches and setup-time Whisper model downloads - Vault content that may be user-controlled or, in some deployments, third-party-controlled @@ -84,6 +86,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - Local secrets and vault contents should not leak through logs or insecure default storage/permissions - Container networking should keep the MCP server private by default - Plugin MCP Containers should remain an opt-in, local-file-only experimental surface; there must be no remote API, setup wizard path, or dynamic client registration path that can add or modify these containers. +- Plugin MCP widget asset ingress should be limited to `GET`/`OPTIONS`, scoped to explicit per-container mounts derived from configured plugin names, and must not provide bearer-token-free access to `/mcp` or vault data. - Native transcription should enforce bounded downloads and reject bytes that cannot be decoded as audio before running Whisper inference ### Assumptions @@ -97,6 +100,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron - The gateway's SIGUSR1 diagnostics trigger is local-only: a sender must already be able to signal the gateway process, and the dump is written only to that process's stdout plus a log-file marker. The dump includes the MCP container's own logs, so the container must continue to avoid logging secrets; trace-level body logging remains covered by Finding 4. - Plugin MCP Containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `` and are trusted at the same level as the core vault MCP container once started. Individual plugins may opt out of internal-only networking with `network_isolation: false` in `brain3.yaml`, restoring outbound egress without adding remote ingress or changing this local-file-only trust boundary. - Plugin MCP Container tool metadata and tool results are not sandboxed or semantically vetted by Brain3 before being appended to `tools/list` or returned from `tools/call`. This matches the current trust model for the vault container's tools and must be treated as trusted local-container output, not as untrusted internet content. +- Plugin MCP widget static assets are exposed without OAuth under `//mcp-use/**` on the public gateway router so browser widget sandboxes can load JavaScript and CSS. Each mount maps to exactly one initialized managed Plugin MCP Container; the gateway strips only the mount prefix and rejects paths outside `mcp-use/**`. `Access-Control-Allow-Origin: *` applies to these credential-free static asset responses only, not to authenticated `/mcp` routes. - Docker-backed Plugin MCP Containers on macOS are reached through a loopback-published host port because Docker Desktop does not expose published ports from internal-only networks. This remains local-only ingress, but it is not the same internal-network-only posture used by Linux Docker Plugin MCP Containers. ## Findings diff --git a/apps/gateway/src/server.rs b/apps/gateway/src/server.rs index ef27e49..89bcef2 100644 --- a/apps/gateway/src/server.rs +++ b/apps/gateway/src/server.rs @@ -14,7 +14,9 @@ use tokio::task::JoinHandle; use brain3_core::application::proxy_mcp::ProxyMcpUseCase; use brain3_core::application::remote_mcp_container_client::RemoteMcpContainerClient; -use brain3_core::domain::model::{AccessMode, GatewayConfig, PluginMcpContainerAuth}; +use brain3_core::domain::model::{ + plugin_mcp_container_asset_mount, AccessMode, GatewayConfig, PluginMcpContainerAuth, +}; use brain3_core::domain::setup::{RuntimeLaunchPlan, RuntimeStartupPolicy}; use brain3_core::ports::config::ConfigPort; use brain3_core::ports::native_mcp_tool::NativeMcpTool; @@ -449,6 +451,13 @@ async fn build_gateway_state( )?)); let plugin_clients = build_plugin_mcp_clients(Arc::clone(&mcp_proxy), plugin_mcp_containers).await; + let plugin_asset_mounts = plugin_clients + .iter() + .map(|client| brain3_platform::http::state::PluginAssetMount { + mount: plugin_mcp_container_asset_mount(client.container_name()), + client: Arc::clone(client), + }) + .collect::>(); let mcp_router = Arc::new(McpRouterUseCase::new_with_plugin_containers( proxy_mcp, native_tools, @@ -463,6 +472,7 @@ async fn build_gateway_state( proxy_mcp: mcp_router, config, rate_limiter: Arc::new(OAuthRateLimiter::new()), + plugin_asset_mounts: Arc::new(plugin_asset_mounts), }; Ok(app_state) diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index de5bc2c..1e8a37d 100644 --- a/crates/core/src/application/mcp_router.rs +++ b/crates/core/src/application/mcp_router.rs @@ -9,6 +9,7 @@ use crate::application::validate_request::validate_host; use crate::domain::errors::ProxyError; use crate::domain::model::HostnameValidationConfig; use crate::ports::mcp_proxy::{McpProxyPort, McpProxyResponse}; +use crate::ports::native_mcp_resource::{NativeMcpResourceContent, NativeMcpResourceError}; use crate::ports::native_mcp_tool::{NativeMcpToolError, NativeMcpToolOutput}; pub struct McpRouterUseCase { @@ -94,9 +95,11 @@ impl McpRouterUseCase

{ .initialize_all() .await .map_err(native_tool_error_to_proxy_error)?; - self.proxy + let response = self + .proxy .handle_unvalidated(request_host, method, path, query, headers, body) - .await + .await?; + Ok(self.patch_initialize_resources_capability(response)) } Some("tools/list") => { let response = self @@ -105,6 +108,13 @@ impl McpRouterUseCase

{ .await?; Ok(self.append_tool_schemas(response)) } + Some("resources/list") => { + let response = self + .proxy + .handle_unvalidated(request_host, method, path, query, headers, body) + .await?; + Ok(self.append_resource_schemas(response)) + } Some("tools/call") => { if let Some(response) = self.maybe_call_native_tool(parsed_body.as_ref()).await? { return Ok(response); @@ -117,6 +127,24 @@ impl McpRouterUseCase

{ .handle_unvalidated(request_host, method, path, query, headers, body) .await } + Some("resources/read") => { + if let Some(response) = self + .maybe_read_native_resource(parsed_body.as_ref()) + .await? + { + return Ok(response); + } + if let Some(response) = self + .maybe_read_plugin_resource(parsed_body.as_ref()) + .await? + { + return Ok(response); + } + + self.proxy + .handle_unvalidated(request_host, method, path, query, headers, body) + .await + } _ => { self.proxy .handle_unvalidated(request_host, method, path, query, headers, body) @@ -224,6 +252,104 @@ impl McpRouterUseCase

{ Ok(None) } + async fn maybe_read_native_resource( + &self, + request: Option<&Value>, + ) -> Result, ProxyError> { + let Some(request) = request else { + return Ok(None); + }; + let Some(params) = request.get("params").and_then(Value::as_object) else { + return Ok(None); + }; + let Some(uri) = params.get("uri").and_then(Value::as_str) else { + return Ok(None); + }; + let Some(resource) = self.native_tools.find_resource(uri) else { + return Ok(None); + }; + + tracing::info!( + resource_uri = uri, + request_id = ?request.get("id"), + "MCP router: handling native resource read" + ); + + match resource.read().await { + Ok(content) => Ok(Some(native_resource_response( + request, + resource.uri(), + resource.mime_type(), + content, + )?)), + Err(error) => { + tracing::warn!( + resource_uri = uri, + request_id = ?request.get("id"), + error = %error, + "MCP router: native resource read failed" + ); + Ok(Some(native_resource_error_response(request, uri, &error))) + } + } + } + + async fn maybe_read_plugin_resource( + &self, + request: Option<&Value>, + ) -> Result, ProxyError> { + let Some(request) = request else { + return Ok(None); + }; + let Some(params) = request.get("params").and_then(Value::as_object) else { + return Ok(None); + }; + let Some(uri) = params.get("uri").and_then(Value::as_str) else { + return Ok(None); + }; + + for client in &self.plugin_containers { + if let Some(original_uri) = client.strip_resource_uri_prefix(uri) { + if !client.has_resource(&original_uri) { + tracing::warn!( + container = %client.container_name(), + prefixed_resource_uri = uri, + resource_uri = original_uri, + request_id = ?request.get("id"), + "MCP router: rejecting read of unadvertised Plugin MCP resource" + ); + return Ok(Some(resource_not_found_response(request, uri))); + } + + tracing::info!( + container = %client.container_name(), + prefixed_resource_uri = uri, + resource_uri = original_uri, + request_id = ?request.get("id"), + "MCP router: routing Plugin MCP resource read" + ); + + match client.read_resource(request, &original_uri).await { + Ok(response) => return Ok(Some(response)), + Err(error) => { + tracing::error!( + container = %client.container_name(), + resource_uri = original_uri, + request_id = ?request.get("id"), + error = %error, + "MCP router: Plugin MCP resource read failed" + ); + return Ok(Some(plugin_resource_transport_error_response( + request, uri, &error, + ))); + } + } + } + } + + Ok(None) + } + /// Logs the full set of MCP tools brain3 is proxying, split into core /// (upstream vault-tools container + native tools) and plugin (one line /// per Plugin MCP Container) groups. Intended to be called once, right @@ -330,28 +456,14 @@ impl McpRouterUseCase

{ return response; } - let Ok(mut body) = serde_json::from_slice::(&response.body) else { - tracing::warn!("MCP router: could not parse tools/list response body as JSON"); - return response; - }; - - let Some(tools) = body - .get_mut("result") - .and_then(|result| result.get_mut("tools")) - .and_then(Value::as_array_mut) - else { - tracing::warn!("MCP router: tools/list response did not contain result.tools array"); - return response; - }; - let native_tool_count = native_schemas.len(); let plugin_tool_count = plugin_schemas.len(); - tools.extend(native_schemas); - tools.extend(plugin_schemas); - let total_tool_count = tools.len(); + let mut schemas = native_schemas; + schemas.extend(plugin_schemas); - let Ok(new_body) = serde_json::to_vec(&body) else { - tracing::warn!("MCP router: could not serialize augmented tools/list response"); + let (response, total_tool_count) = + append_result_array(response, "tools/list", "tools", schemas); + let Some(total_tool_count) = total_tool_count else { return response; }; @@ -362,12 +474,151 @@ impl McpRouterUseCase

{ "MCP router: appended native and Plugin MCP tools to tools/list response" ); + response + } + + fn append_resource_schemas(&self, response: McpProxyResponse) -> McpProxyResponse { + let native_schemas = self.native_tools.list_resource_schemas(); + let plugin_schemas = self + .plugin_containers + .iter() + .flat_map(|client| client.prefixed_resource_schemas()) + .collect::>(); + + if native_schemas.is_empty() && plugin_schemas.is_empty() { + return response; + } + + let native_resource_count = native_schemas.len(); + let plugin_resource_count = plugin_schemas.len(); + let mut schemas = native_schemas; + schemas.extend(plugin_schemas); + + let (response, total_resource_count) = + append_result_array(response, "resources/list", "resources", schemas); + let Some(total_resource_count) = total_resource_count else { + return response; + }; + + tracing::info!( + native_resource_count = native_resource_count, + plugin_resource_count = plugin_resource_count, + total_resource_count = total_resource_count, + "MCP router: appended native and Plugin MCP resources to resources/list response" + ); + + response + } + + fn patch_initialize_resources_capability( + &self, + response: McpProxyResponse, + ) -> McpProxyResponse { + if !self.has_resources() { + tracing::debug!( + "MCP router: initialize response resources capability patch skipped; no resources are registered" + ); + return response; + } + + let Ok(mut body) = serde_json::from_slice::(&response.body) else { + tracing::warn!("MCP router: could not parse initialize response body as JSON"); + return response; + }; + if body.get("error").is_some() { + tracing::debug!( + "MCP router: initialize response resources capability patch skipped; response contains JSON-RPC error" + ); + return response; + } + + let Some(result) = body.get_mut("result").and_then(Value::as_object_mut) else { + tracing::warn!("MCP router: initialize response did not contain result object"); + return response; + }; + let capabilities = result.entry("capabilities").or_insert_with(|| json!({})); + let Some(capabilities) = capabilities.as_object_mut() else { + tracing::warn!("MCP router: initialize response capabilities was not an object"); + return response; + }; + + if capabilities.contains_key("resources") { + tracing::debug!( + "MCP router: initialize response already includes resources capability; no patch needed" + ); + return response; + } + capabilities.insert("resources".into(), json!({})); + + let Ok(new_body) = serde_json::to_vec(&body) else { + tracing::warn!("MCP router: could not serialize patched initialize response"); + return response; + }; + + tracing::info!("MCP router: added resources capability to initialize response"); + McpProxyResponse { status: response.status, headers: strip_content_length(response.headers), body: new_body, } } + + fn has_resources(&self) -> bool { + self.native_tools.has_resources() + || self + .plugin_containers + .iter() + .any(|client| !client.prefixed_resource_schemas().is_empty()) + } +} + +fn append_result_array( + response: McpProxyResponse, + method_name: &str, + array_key: &str, + additions: Vec, +) -> (McpProxyResponse, Option) { + let Ok(mut body) = serde_json::from_slice::(&response.body) else { + tracing::warn!( + method = method_name, + "MCP router: could not parse response body as JSON" + ); + return (response, None); + }; + + let Some(values) = body + .get_mut("result") + .and_then(|result| result.get_mut(array_key)) + .and_then(Value::as_array_mut) + else { + tracing::warn!( + method = method_name, + array_key, + "MCP router: response did not contain expected result array" + ); + return (response, None); + }; + + values.extend(additions); + let total_count = values.len(); + + let Ok(new_body) = serde_json::to_vec(&body) else { + tracing::warn!( + method = method_name, + "MCP router: could not serialize augmented response" + ); + return (response, None); + }; + + ( + McpProxyResponse { + status: response.status, + headers: strip_content_length(response.headers), + body: new_body, + }, + Some(total_count), + ) } fn tool_names(schemas: &[Value]) -> Vec { @@ -419,6 +670,47 @@ fn native_tool_response( }) } +fn native_resource_response( + request: &Value, + uri: &str, + mime_type: &str, + content: NativeMcpResourceContent, +) -> Result { + let mut resource_content = json!({ + "uri": uri, + "mimeType": mime_type, + }); + let object = resource_content + .as_object_mut() + .expect("native resource content should be a JSON object"); + match content { + NativeMcpResourceContent::Text(text) => { + object.insert("text".into(), Value::String(text)); + } + NativeMcpResourceContent::Blob(blob) => { + object.insert("blob".into(), Value::String(blob)); + } + } + + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "result": { + "contents": [resource_content], + } + }); + + let body = serde_json::to_vec(&body).map_err(|error| { + ProxyError::BadGateway(format!("native MCP resource response error: {error}")) + })?; + + Ok(McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + }) +} + fn native_tool_error_to_proxy_error(error: NativeMcpToolError) -> ProxyError { ProxyError::BadGateway(format!("native MCP tool initialization failed: {error}")) } @@ -442,6 +734,48 @@ fn tool_not_found_response(request: &Value, tool_name: &str) -> McpProxyResponse } } +fn resource_not_found_response(request: &Value, resource_uri: &str) -> McpProxyResponse { + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32602, + "message": format!("Resource not found: {resource_uri}"), + } + }); + + let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + } +} + +fn native_resource_error_response( + request: &Value, + resource_uri: &str, + error: &NativeMcpResourceError, +) -> McpProxyResponse { + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32603, + "message": format!("Native resource error reading {resource_uri}: {error}"), + } + }); + + let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + } +} + fn plugin_transport_error_response( request: &Value, tool_name: &str, @@ -465,6 +799,29 @@ fn plugin_transport_error_response( } } +fn plugin_resource_transport_error_response( + request: &Value, + resource_uri: &str, + error: &ProxyError, +) -> McpProxyResponse { + let body = json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32603, + "message": format!("Plugin container error reading {resource_uri}: {error}"), + } + }); + + let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body, + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -478,13 +835,29 @@ mod tests { use crate::domain::errors::ProxyError; use crate::domain::model::HostnameValidationConfig; use crate::ports::mcp_proxy::{McpProxyPort, McpProxyRequest, McpProxyResponse}; + use crate::ports::native_mcp_resource::{ + NativeMcpResource, NativeMcpResourceContent, NativeMcpResourceError, + }; use crate::ports::native_mcp_tool::{NativeMcpTool, NativeMcpToolError, NativeMcpToolOutput}; use super::*; struct CapturingProxy { captured: Arc>>, - response_body: Vec, + response_bodies: Arc>>>, + } + + impl CapturingProxy { + fn new(response_body: Vec) -> Self { + Self::new_sequence(vec![response_body]) + } + + fn new_sequence(response_bodies: Vec>) -> Self { + Self { + captured: Arc::new(Mutex::new(Vec::new())), + response_bodies: Arc::new(Mutex::new(response_bodies)), + } + } } #[async_trait] @@ -494,16 +867,27 @@ mod tests { .lock() .expect("capture lock should succeed") .push(request); + let response_body = { + let mut response_bodies = self + .response_bodies + .lock() + .expect("response bodies lock should succeed"); + if response_bodies.len() > 1 { + response_bodies.remove(0) + } else { + response_bodies + .first() + .expect("test proxy should have a response body") + .clone() + } + }; Ok(McpProxyResponse { status: 200, headers: vec![ ("content-type".into(), "application/json".into()), - ( - "content-length".into(), - self.response_body.len().to_string(), - ), + ("content-length".into(), response_body.len().to_string()), ], - body: self.response_body.clone(), + body: response_body, }) } } @@ -559,36 +943,133 @@ mod tests { } } - fn router_with_tool( - proxy_body: Vec, - ) -> ( - McpRouterUseCase, - Arc>>, - Arc, - ) { - let captured = Arc::new(Mutex::new(Vec::new())); - let proxy = Arc::new(CapturingProxy { - captured: Arc::clone(&captured), - response_body: proxy_body, - }); - let proxy_use_case = Arc::new(ProxyMcpUseCase::new( - proxy, - "http://127.0.0.1:8420".into(), - "shared-secret".into(), - HostnameValidationConfig { - expected_host: None, - enforce: true, - }, - )); + struct FakeNativeResource { + reads: Arc>, + } + + impl FakeNativeResource { + fn new() -> Self { + Self { + reads: Arc::new(Mutex::new(0)), + } + } + } + + #[async_trait] + impl NativeMcpResource for FakeNativeResource { + fn uri(&self) -> &str { + "ui://brain3-native/fake/index.html" + } + + fn name(&self) -> &str { + "Fake native resource" + } + + fn mime_type(&self) -> &str { + "text/html" + } + + async fn read(&self) -> Result { + *self.reads.lock().expect("reads lock should succeed") += 1; + Ok(NativeMcpResourceContent::text("

native widget
")) + } + } + + fn router_with_tool( + proxy_body: Vec, + ) -> ( + McpRouterUseCase, + Arc>>, + Arc, + ) { + let proxy = Arc::new(CapturingProxy::new(proxy_body)); + let proxy_use_case = Arc::new(ProxyMcpUseCase::new( + Arc::clone(&proxy), + "http://127.0.0.1:8420".into(), + "shared-secret".into(), + HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + )); let tool = Arc::new(FakeNativeTool::new()); let registry = NativeMcpToolRegistry::new(vec![tool.clone() as Arc]); ( McpRouterUseCase::new(proxy_use_case, Arc::new(registry)), - captured, + Arc::clone(&proxy.captured), tool, ) } + fn router_with_tool_and_resource( + proxy_body: Vec, + ) -> ( + McpRouterUseCase, + Arc>>, + Arc, + ) { + let proxy = Arc::new(CapturingProxy::new(proxy_body)); + let proxy_use_case = Arc::new(ProxyMcpUseCase::new( + Arc::clone(&proxy), + "http://127.0.0.1:8420".into(), + "shared-secret".into(), + HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + )); + let tool = Arc::new(FakeNativeTool::new()); + let resource = Arc::new(FakeNativeResource::new()); + let registry = NativeMcpToolRegistry::new_with_resources( + vec![tool as Arc], + vec![resource.clone() as Arc], + ); + ( + McpRouterUseCase::new(proxy_use_case, Arc::new(registry)), + Arc::clone(&proxy.captured), + resource, + ) + } + + fn router_with_tool_resource_and_plugin( + proxy_body: Vec, + plugin_proxy: Arc, + plugin_client: Arc>, + ) -> ( + McpRouterUseCase, + Arc>>, + Arc, + Arc>>, + ) { + let proxy = Arc::new(CapturingProxy::new(proxy_body)); + let proxy_use_case = Arc::new(ProxyMcpUseCase::new( + Arc::clone(&proxy), + "http://127.0.0.1:8420".into(), + "shared-secret".into(), + HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + )); + let tool = Arc::new(FakeNativeTool::new()); + let resource = Arc::new(FakeNativeResource::new()); + let registry = NativeMcpToolRegistry::new_with_resources( + vec![tool as Arc], + vec![resource.clone() as Arc], + ); + let plugin_captured = Arc::clone(&plugin_proxy.captured); + ( + McpRouterUseCase::new_with_plugin_containers( + proxy_use_case, + Arc::new(registry), + vec![plugin_client], + ), + Arc::clone(&proxy.captured), + resource, + plugin_captured, + ) + } + fn router_with_tool_and_plugin( proxy_body: Vec, plugin_proxy: Arc, @@ -599,13 +1080,9 @@ mod tests { Arc, Arc>>, ) { - let captured = Arc::new(Mutex::new(Vec::new())); - let proxy = Arc::new(CapturingProxy { - captured: Arc::clone(&captured), - response_body: proxy_body, - }); + let proxy = Arc::new(CapturingProxy::new(proxy_body)); let proxy_use_case = Arc::new(ProxyMcpUseCase::new( - proxy, + Arc::clone(&proxy), "http://127.0.0.1:8420".into(), "shared-secret".into(), HostnameValidationConfig { @@ -622,7 +1099,7 @@ mod tests { Arc::new(registry), vec![plugin_client], ), - captured, + Arc::clone(&proxy.captured), tool, plugin_captured, ) @@ -634,11 +1111,36 @@ mod tests { Arc, Arc>, ) { - let captured = Arc::new(Mutex::new(Vec::new())); - let proxy = Arc::new(CapturingProxy { - captured, + let proxy = Arc::new(CapturingProxy::new_sequence(vec![ + br#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_vec(), response_body, - }); + br#"{"jsonrpc":"2.0","id":2,"result":{"resources":[]}}"#.to_vec(), + ])); + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + Arc::clone(&proxy), + ) + .await + .expect("plugin client should initialize"); + (proxy, Arc::new(client)) + } + + async fn initialized_plugin_client_with_resources( + resources_body: Vec, + tools_body: Vec, + read_body: Vec, + ) -> ( + Arc, + Arc>, + ) { + let proxy = Arc::new(CapturingProxy::new_sequence(vec![ + br#"{"jsonrpc":"2.0","id":1,"result":{}}"#.to_vec(), + tools_body, + resources_body, + read_body, + ])); let client = RemoteMcpContainerClient::initialize_and_cache_tools( "fluensy_learn".into(), "http://127.0.0.1:18420/mcp".into(), @@ -826,8 +1328,8 @@ mod tests { .lock() .expect("plugin capture lock should succeed") .len(), - 2, - "plugin client should only have startup initialize and tools/list calls" + 3, + "plugin client should only have startup initialize, tools/list, and resources/list calls" ); let body: Value = @@ -893,9 +1395,9 @@ mod tests { let plugin_requests = plugin_captured .lock() .expect("plugin capture lock should succeed"); - assert_eq!(plugin_requests.len(), 3); + assert_eq!(plugin_requests.len(), 4); let body: Value = - serde_json::from_slice(&plugin_requests[2].body).expect("request should be JSON"); + serde_json::from_slice(&plugin_requests[3].body).expect("request should be JSON"); assert_eq!(body["params"]["name"], "search_deck"); assert_eq!(body["params"]["arguments"]["query"], "rust"); } @@ -983,8 +1485,300 @@ mod tests { .expect("plugin capture lock should succeed"); assert_eq!( plugin_requests.len(), - 2, - "plugin should only receive initialize and tools/list, not the rejected tool call" + 3, + "plugin should only receive startup calls, not the rejected tool call" + ); + } + + #[tokio::test] + async fn resources_list_forwards_to_proxy_and_appends_native_and_plugin_resources() { + let (plugin_proxy, plugin_client) = initialized_plugin_client_with_resources( + json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "resources": [ + { + "uri": "ui://widget-name/index.html", + "name": "Plugin widget", + "mimeType": "text/html" + } + ] + } + }) + .to_string() + .into_bytes(), + br#"{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}"#.to_vec(), + br#"{"jsonrpc":"2.0","id":4,"result":{"contents":[]}}"#.to_vec(), + ) + .await; + let (router, captured, _, plugin_captured) = router_with_tool_resource_and_plugin( + json!({ + "jsonrpc": "2.0", + "id": 5, + "result": { + "resources": [ + { + "uri": "ui://core/resource.html", + "name": "Core resource", + "mimeType": "text/html" + } + ] + } + }) + .to_string() + .into_bytes(), + plugin_proxy, + plugin_client, + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "resources/list" + }) + .to_string() + .into_bytes(), + ) + .await + .expect("resources/list should succeed"); + + assert_eq!( + captured.lock().expect("capture lock should succeed").len(), + 1, + "vault proxy should still receive resources/list" + ); + assert_eq!( + plugin_captured + .lock() + .expect("plugin capture lock should succeed") + .len(), + 3, + "plugin client should only have startup calls" + ); + + let body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + let resources = body["result"]["resources"] + .as_array() + .expect("resources should be an array"); + assert_eq!(resources.len(), 3); + assert_eq!(resources[0]["uri"], "ui://core/resource.html"); + assert_eq!(resources[1]["uri"], "ui://brain3-native/fake/index.html"); + assert_eq!( + resources[2]["uri"], + "ui://fluensy_learn__widget-name/index.html" + ); + assert!( + response + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("content-length")), + "augmented resources/list responses must not retain the upstream content-length" + ); + } + + #[tokio::test] + async fn resources_read_for_native_resource_bypasses_proxy() { + let (router, captured, resource) = + router_with_tool_and_resource(br#"{"jsonrpc":"2.0","result":{}}"#.to_vec()); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "resources/read", + "params": { "uri": "ui://brain3-native/fake/index.html" } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("native resource read should succeed"); + + assert!(captured + .lock() + .expect("capture lock should succeed") + .is_empty()); + assert_eq!( + *resource.reads.lock().expect("reads lock should succeed"), + 1 + ); + + let body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + assert_eq!(body["id"], 8); + assert_eq!( + body["result"]["contents"][0]["uri"], + "ui://brain3-native/fake/index.html" + ); + assert_eq!(body["result"]["contents"][0]["mimeType"], "text/html"); + assert_eq!( + body["result"]["contents"][0]["text"], + "
native widget
" + ); + } + + #[tokio::test] + async fn resources_read_for_prefixed_plugin_resource_routes_to_container_and_strips_prefix() { + let (plugin_proxy, plugin_client) = initialized_plugin_client_with_resources( + br#"{"jsonrpc":"2.0","id":2,"result":{"resources":[{"uri":"ui://widget-name/index.html","name":"Plugin widget","mimeType":"text/html"}]}}"#.to_vec(), + br#"{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}"#.to_vec(), + br#"{"jsonrpc":"2.0","id":9,"result":{"contents":[{"uri":"ui://widget-name/index.html","mimeType":"text/html","text":"
plugin
"}]}}"#.to_vec(), + ) + .await; + let (router, captured, _, plugin_captured) = router_with_tool_and_plugin( + br#"{"jsonrpc":"2.0","result":{}}"#.to_vec(), + plugin_proxy, + plugin_client, + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "resources/read", + "params": { "uri": "ui://fluensy_learn__widget-name/index.html" } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("plugin resource read should succeed"); + + assert!(captured + .lock() + .expect("vault capture lock should succeed") + .is_empty()); + assert_eq!(response.status, 200); + let plugin_requests = plugin_captured + .lock() + .expect("plugin capture lock should succeed"); + assert_eq!(plugin_requests.len(), 4); + let body: Value = + serde_json::from_slice(&plugin_requests[3].body).expect("request should be JSON"); + assert_eq!(body["method"], "resources/read"); + assert_eq!(body["params"]["uri"], "ui://widget-name/index.html"); + drop(plugin_requests); + + let response_body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + assert_eq!( + response_body["result"]["contents"][0]["uri"], + "ui://fluensy_learn__widget-name/index.html" + ); + assert_eq!( + response_body["result"]["contents"][0]["text"], + "
plugin
" + ); + assert!( + response + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("content-length")), + "rewritten plugin resources/read responses must not retain the upstream content-length" + ); + } + + #[tokio::test] + async fn resources_read_falls_through_to_core_proxy_for_unrecognized_uri() { + let (router, captured, _) = + router_with_tool_and_resource(br#"{"jsonrpc":"2.0","id":10,"result":{}}"#.to_vec()); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 10, + "method": "resources/read", + "params": { "uri": "ui://unknown/resource.html" } + }) + .to_string() + .into_bytes(), + ) + .await + .expect("unrecognized resource should fall through"); + + assert_eq!(response.status, 200); + assert_eq!( + captured.lock().expect("capture lock should succeed").len(), + 1 + ); + } + + #[tokio::test] + async fn initialize_response_gains_resources_capability_when_resources_exist() { + let (router, captured, _) = router_with_tool_and_resource( + json!({ + "jsonrpc": "2.0", + "id": 11, + "result": { + "capabilities": { + "tools": {} + } + } + }) + .to_string() + .into_bytes(), + ); + + let response = router + .handle( + "brain3.example.com", + "POST", + "/mcp", + None, + vec![("content-type".into(), "application/json".into())], + json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "initialize", + "params": {} + }) + .to_string() + .into_bytes(), + ) + .await + .expect("initialize should succeed"); + + assert_eq!( + captured.lock().expect("capture lock should succeed").len(), + 1 + ); + let body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + assert_eq!(body["result"]["capabilities"]["tools"], json!({})); + assert_eq!(body["result"]["capabilities"]["resources"], json!({})); + assert!( + response + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("content-length")), + "patched initialize response must not retain upstream content-length" ); } } diff --git a/crates/core/src/application/native_mcp_tool_registry.rs b/crates/core/src/application/native_mcp_tool_registry.rs index 723d034..4fd3498 100644 --- a/crates/core/src/application/native_mcp_tool_registry.rs +++ b/crates/core/src/application/native_mcp_tool_registry.rs @@ -2,21 +2,40 @@ use std::sync::Arc; use serde_json::{json, Value}; +use crate::ports::native_mcp_resource::NativeMcpResource; use crate::ports::native_mcp_tool::{NativeMcpTool, NativeMcpToolError}; pub struct NativeMcpToolRegistry { tools: Vec>, + resources: Vec>, } impl NativeMcpToolRegistry { pub fn new(tools: Vec>) -> Self { - Self { tools } + Self { + tools, + resources: Vec::new(), + } + } + + pub fn new_with_resources( + tools: Vec>, + resources: Vec>, + ) -> Self { + Self { tools, resources } } pub fn find(&self, name: &str) -> Option> { self.tools.iter().find(|tool| tool.name() == name).cloned() } + pub fn find_resource(&self, uri: &str) -> Option> { + self.resources + .iter() + .find(|resource| resource.uri() == uri) + .cloned() + } + pub fn list_schemas(&self) -> Vec { self.tools .iter() @@ -37,6 +56,23 @@ impl NativeMcpToolRegistry { .collect() } + pub fn list_resource_schemas(&self) -> Vec { + self.resources + .iter() + .map(|resource| { + json!({ + "uri": resource.uri(), + "name": resource.name(), + "mimeType": resource.mime_type(), + }) + }) + .collect() + } + + pub fn has_resources(&self) -> bool { + !self.resources.is_empty() + } + pub async fn initialize_all(&self) -> Result<(), NativeMcpToolError> { for tool in &self.tools { tool.on_initialize().await?; diff --git a/crates/core/src/application/remote_mcp_container_client.rs b/crates/core/src/application/remote_mcp_container_client.rs index 69a561c..af3397f 100644 --- a/crates/core/src/application/remote_mcp_container_client.rs +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -13,12 +13,20 @@ pub struct RemoteMcpContainerToolSchema { pub schema: Value, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteMcpContainerResourceSchema { + pub prefixed_uri: String, + pub original_uri: String, + pub schema: Value, +} + pub struct RemoteMcpContainerClient { container_name: String, mcp_url: String, bearer_token: Option, proxy: Arc

, tool_schemas: Vec, + resource_schemas: Vec, } impl RemoteMcpContainerClient

{ @@ -34,6 +42,7 @@ impl RemoteMcpContainerClient

{ bearer_token, proxy, tool_schemas: Vec::new(), + resource_schemas: Vec::new(), }; tracing::info!( @@ -43,16 +52,20 @@ impl RemoteMcpContainerClient

{ "initializing Plugin MCP Container client" ); client.initialize().await?; - let tool_schemas = client.fetch_prefixed_tool_schemas().await?; + let tool_schemas = client.fetch_tool_schemas().await?; + let resource_schemas = client.fetch_prefixed_resource_schemas().await?; + let tool_schemas = client.prefix_tool_schemas(&tool_schemas, &resource_schemas); tracing::info!( container = %client.container_name, tool_count = tool_schemas.len(), + resource_count = resource_schemas.len(), "cached Plugin MCP Container tool schemas" ); Ok(Self { tool_schemas, + resource_schemas, ..client }) } @@ -61,6 +74,14 @@ impl RemoteMcpContainerClient

{ &self.container_name } + pub fn http_base_url(&self) -> String { + self.mcp_url + .strip_suffix("/mcp") + .unwrap_or(&self.mcp_url) + .trim_end_matches('/') + .to_string() + } + pub fn prefixed_tool_schemas(&self) -> Vec { self.tool_schemas .iter() @@ -68,6 +89,13 @@ impl RemoteMcpContainerClient

{ .collect() } + pub fn prefixed_resource_schemas(&self) -> Vec { + self.resource_schemas + .iter() + .map(|resource| resource.schema.clone()) + .collect() + } + pub fn strip_prefix<'a>(&self, tool_name: &'a str) -> Option<&'a str> { tool_name .strip_prefix(self.container_name.as_str()) @@ -81,6 +109,16 @@ impl RemoteMcpContainerClient

{ .any(|schema| schema.original_name == unprefixed_tool_name) } + pub fn strip_resource_uri_prefix(&self, uri: &str) -> Option { + strip_resource_uri_prefix_for_container(&self.container_name, uri) + } + + pub fn has_resource(&self, original_uri: &str) -> bool { + self.resource_schemas + .iter() + .any(|schema| schema.original_uri == original_uri) + } + pub async fn call_tool( &self, request: &Value, @@ -110,7 +148,82 @@ impl RemoteMcpContainerClient

{ "forwarding tools/call to Plugin MCP Container" ); - self.forward_json(body).await + let response = self.forward_json(body).await?; + tracing::debug!( + container = %self.container_name, + tool_name = unprefixed_tool_name, + request_id = ?request.get("id"), + status = response.status, + body_bytes = response.body.len(), + "received tools/call response from Plugin MCP Container" + ); + + Ok(response) + } + + pub async fn read_resource( + &self, + request: &Value, + original_uri: &str, + ) -> Result { + let mut forwarded = request.clone(); + let Some(params) = forwarded.get_mut("params").and_then(Value::as_object_mut) else { + return Err(ProxyError::BadGateway( + "Plugin MCP resources/read request missing params object".into(), + )); + }; + params.insert("uri".into(), Value::String(original_uri.to_string())); + + let body = serde_json::to_vec(&forwarded).map_err(|error| { + ProxyError::BadGateway(format!( + "failed to serialize Plugin MCP resources/read: {error}" + )) + })?; + + tracing::info!( + container = %self.container_name, + resource_uri = original_uri, + request_id = ?request.get("id"), + "forwarding resources/read to Plugin MCP Container" + ); + + let response = self.forward_json(body).await?; + tracing::debug!( + container = %self.container_name, + resource_uri = original_uri, + request_id = ?request.get("id"), + status = response.status, + body_bytes = response.body.len(), + "received resources/read response from Plugin MCP Container" + ); + + Ok(self.prefix_read_resource_response_uri(response, original_uri)) + } + + pub async fn get_http_path( + &self, + path: &str, + query: Option<&str>, + headers: Vec<(String, String)>, + ) -> Result { + let upstream_url = self.build_http_url(path, query); + let filtered_headers = filter_static_asset_request_headers(headers); + + tracing::info!( + container = %self.container_name, + upstream_url = %upstream_url, + path = path, + "forwarding Plugin MCP Container HTTP GET" + ); + + self.proxy + .forward(McpProxyRequest { + method: "GET".into(), + url: upstream_url, + headers: filtered_headers, + body: Vec::new(), + }) + .await } async fn initialize(&self) -> Result<(), ProxyError> { @@ -139,9 +252,7 @@ impl RemoteMcpContainerClient

{ self.require_success("initialize", &response) } - async fn fetch_prefixed_tool_schemas( - &self, - ) -> Result, ProxyError> { + async fn fetch_tool_schemas(&self) -> Result, ProxyError> { let response = self .forward_json( serde_json::to_vec(&json!({ @@ -176,6 +287,14 @@ impl RemoteMcpContainerClient

{ )) })?; + Ok(tools.clone()) + } + + fn prefix_tool_schemas( + &self, + tools: &[Value], + resource_schemas: &[RemoteMcpContainerResourceSchema], + ) -> Vec { let mut schemas = Vec::new(); for tool in tools { let Some(original_name) = tool.get("name").and_then(Value::as_str) else { @@ -200,6 +319,7 @@ impl RemoteMcpContainerClient

{ ); continue; } + self.rewrite_tool_resource_uri(&mut schema, resource_schemas); schemas.push(RemoteMcpContainerToolSchema { prefixed_name: format!("{}__{}", self.container_name, original_name), @@ -208,9 +328,219 @@ impl RemoteMcpContainerClient

{ }); } + schemas + } + + async fn fetch_prefixed_resource_schemas( + &self, + ) -> Result, ProxyError> { + let response = self + .forward_json( + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": format!("brain3-plugin-{}-resources-list", self.container_name), + "method": "resources/list", + "params": {} + })) + .map_err(|error| { + ProxyError::BadGateway(format!( + "failed to serialize Plugin MCP resources/list: {error}" + )) + })?, + ) + .await?; + self.require_success("resources/list", &response)?; + + let body = serde_json::from_slice::(&response.body).map_err(|error| { + ProxyError::BadGateway(format!( + "Plugin MCP Container '{}' returned invalid resources/list JSON: {error}", + self.container_name + )) + })?; + if let Some(error) = body.get("error") { + tracing::info!( + container = %self.container_name, + error = %error, + "Plugin MCP Container resources/list returned JSON-RPC error; continuing without resources" + ); + return Ok(Vec::new()); + } + + let Some(resources) = body + .get("result") + .and_then(|result| result.get("resources")) + .and_then(Value::as_array) + else { + tracing::debug!( + container = %self.container_name, + "Plugin MCP Container resources/list response missing result.resources array; continuing without resources" + ); + return Ok(Vec::new()); + }; + + let mut schemas = Vec::new(); + for resource in resources { + let Some(original_uri) = resource.get("uri").and_then(Value::as_str) else { + tracing::warn!( + container = %self.container_name, + schema = %resource, + "skipping Plugin MCP resource schema without string uri" + ); + continue; + }; + let Some(prefixed_uri) = + prefix_resource_uri_for_container(&self.container_name, original_uri) + else { + tracing::warn!( + container = %self.container_name, + resource_uri = original_uri, + "skipping Plugin MCP resource schema with unprefixable URI" + ); + continue; + }; + let mut schema = resource.clone(); + if let Some(object) = schema.as_object_mut() { + object.insert("uri".into(), Value::String(prefixed_uri.clone())); + } else { + tracing::warn!( + container = %self.container_name, + schema = %resource, + "skipping non-object Plugin MCP resource schema" + ); + continue; + } + + schemas.push(RemoteMcpContainerResourceSchema { + prefixed_uri, + original_uri: original_uri.to_string(), + schema, + }); + } + + tracing::debug!( + container = %self.container_name, + resource_count = schemas.len(), + "cached Plugin MCP Container resource schemas" + ); + Ok(schemas) } + fn rewrite_tool_resource_uri( + &self, + schema: &mut Value, + resource_schemas: &[RemoteMcpContainerResourceSchema], + ) { + let Some(resource_uri) = get_tool_resource_uri(schema).map(str::to_string) else { + return; + }; + let Some(resource_schema) = resource_schemas + .iter() + .find(|resource| resource.original_uri == resource_uri) + else { + tracing::warn!( + container = %self.container_name, + resource_uri, + "Plugin MCP tool schema references a UI resource URI not declared by resources/list" + ); + return; + }; + + let tool_name = schema + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + tracing::info!( + container = %self.container_name, + tool_name, + resource_uri_before = resource_uri, + resource_uri_after = %resource_schema.prefixed_uri, + "rewrote Plugin MCP tool UI resource URI" + ); + set_tool_resource_uri(schema, &resource_schema.prefixed_uri); + } + + fn prefix_read_resource_response_uri( + &self, + response: McpProxyResponse, + original_uri: &str, + ) -> McpProxyResponse { + let Some(prefixed_uri) = + prefix_resource_uri_for_container(&self.container_name, original_uri) + else { + tracing::warn!( + container = %self.container_name, + resource_uri = original_uri, + "could not prefix Plugin MCP resources/read response URI" + ); + return response; + }; + + let Ok(mut body) = serde_json::from_slice::(&response.body) else { + tracing::debug!( + container = %self.container_name, + resource_uri = original_uri, + "Plugin MCP resources/read response was not JSON; passing through" + ); + return response; + }; + if body.get("error").is_some() { + tracing::debug!( + container = %self.container_name, + resource_uri = original_uri, + "Plugin MCP resources/read returned JSON-RPC error; passing through" + ); + return response; + } + + let Some(contents) = body + .get_mut("result") + .and_then(|result| result.get_mut("contents")) + .and_then(Value::as_array_mut) + else { + tracing::debug!( + container = %self.container_name, + resource_uri = original_uri, + "Plugin MCP resources/read response missing result.contents array; passing through" + ); + return response; + }; + + let mut rewrite_count = 0usize; + for content in contents { + let Some(content_object) = content.as_object_mut() else { + continue; + }; + if content_object.get("uri").and_then(Value::as_str) == Some(original_uri) { + content_object.insert("uri".into(), Value::String(prefixed_uri.clone())); + rewrite_count += 1; + } + } + + let Ok(new_body) = serde_json::to_vec(&body) else { + tracing::warn!( + container = %self.container_name, + resource_uri = original_uri, + "could not serialize Plugin MCP resources/read response with prefixed URI" + ); + return response; + }; + + tracing::debug!( + container = %self.container_name, + resource_uri_before = original_uri, + resource_uri_after = %prefixed_uri, + rewrite_count, + "rewrote Plugin MCP resources/read response resource URIs" + ); + + McpProxyResponse { + status: response.status, + headers: strip_content_length(response.headers), + body: new_body, + } + } + async fn forward_json(&self, body: Vec) -> Result { let mut headers = vec![ ("content-type".into(), "application/json".into()), @@ -241,6 +571,15 @@ impl RemoteMcpContainerClient

{ .await } + fn build_http_url(&self, path: &str, query: Option<&str>) -> String { + let normalized_path = path.trim_start_matches('/'); + let query_part = match query { + Some(q) if !q.is_empty() => format!("?{q}"), + _ => String::new(), + }; + format!("{}/{}{}", self.http_base_url(), normalized_path, query_part) + } + fn require_success( &self, operation: &str, @@ -257,6 +596,101 @@ impl RemoteMcpContainerClient

{ } } +fn prefix_resource_uri_for_container(container_name: &str, uri: &str) -> Option { + let (prefix, authority, suffix) = split_uri_authority(uri)?; + Some(format!("{prefix}{container_name}__{authority}{suffix}")) +} + +fn strip_resource_uri_prefix_for_container(container_name: &str, uri: &str) -> Option { + let (prefix, authority, suffix) = split_uri_authority(uri)?; + let original_authority = authority + .strip_prefix(container_name) + .and_then(|rest| rest.strip_prefix("__")) + .filter(|authority| !authority.is_empty())?; + Some(format!("{prefix}{original_authority}{suffix}")) +} + +fn split_uri_authority(uri: &str) -> Option<(&str, &str, &str)> { + let authority_start = uri.find("://")? + 3; + let rest = &uri[authority_start..]; + let authority_end = rest + .find(|character| matches!(character, '/' | '?' | '#')) + .unwrap_or(rest.len()); + if authority_end == 0 { + return None; + } + let authority_absolute_end = authority_start + authority_end; + Some(( + &uri[..authority_start], + &uri[authority_start..authority_absolute_end], + &uri[authority_absolute_end..], + )) +} + +fn get_tool_resource_uri(schema: &Value) -> Option<&str> { + schema + .get("_meta") + .and_then(|meta| { + meta.get("ui") + .and_then(|ui| ui.get("resourceUri")) + .or_else(|| meta.get("ui.resourceUri")) + }) + .and_then(Value::as_str) +} + +fn set_tool_resource_uri(schema: &mut Value, resource_uri: &str) { + let Some(meta) = schema.get_mut("_meta") else { + return; + }; + if let Some(ui) = meta.get_mut("ui").and_then(Value::as_object_mut) { + if ui.contains_key("resourceUri") { + ui.insert( + "resourceUri".into(), + Value::String(resource_uri.to_string()), + ); + return; + } + } + if let Some(meta_object) = meta.as_object_mut() { + if meta_object.contains_key("ui.resourceUri") { + meta_object.insert( + "ui.resourceUri".into(), + Value::String(resource_uri.to_string()), + ); + } + } +} + +fn strip_content_length(headers: Vec<(String, String)>) -> Vec<(String, String)> { + headers + .into_iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case("content-length")) + .collect() +} + +fn filter_static_asset_request_headers(headers: Vec<(String, String)>) -> Vec<(String, String)> { + headers + .into_iter() + .filter(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" + | "connection" + | "content-length" + | "host" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "trailers" + | "transfer-encoding" + | "upgrade" + ) + }) + .collect() +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -306,6 +740,18 @@ mod tests { } } + fn json_response_with_content_length(value: Value) -> McpProxyResponse { + let body = serde_json::to_vec(&value).expect("test JSON should serialize"); + McpProxyResponse { + status: 200, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("content-length".into(), body.len().to_string()), + ], + body, + } + } + #[tokio::test] async fn initialize_and_cache_tools_prefixes_tool_names_and_sends_bearer_auth() { let proxy = Arc::new(CapturingProxy::with_responses(vec![ @@ -323,6 +769,7 @@ mod tests { ] } })), + json_response(json!({"jsonrpc": "2.0", "id": 3, "result": {"resources": []}})), ])); let client = RemoteMcpContainerClient::initialize_and_cache_tools( @@ -340,7 +787,7 @@ mod tests { assert_eq!(schemas[0]["description"], "Search deck"); let requests = proxy.requests.lock().expect("requests lock should succeed"); - assert_eq!(requests.len(), 2); + assert_eq!(requests.len(), 3); assert_eq!(requests[0].url, "http://127.0.0.1:18420/mcp"); assert!(requests[0] .headers @@ -349,11 +796,60 @@ mod tests { && value == "Bearer secret-token")); } + #[tokio::test] + async fn get_http_path_uses_container_http_base_and_filters_browser_auth_headers() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({"jsonrpc": "2.0", "id": 2, "result": {"tools": []}})), + json_response(json!({"jsonrpc": "2.0", "id": 3, "result": {"resources": []}})), + McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/javascript".into())], + body: b"console.log('ok')".to_vec(), + }, + ])); + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy.clone(), + ) + .await + .expect("client should initialize"); + + let response = client + .get_http_path( + "mcp-use/widgets/practice-widget/assets/index.js", + Some("v=1"), + vec![ + ("authorization".into(), "Bearer browser-token".into()), + ("host".into(), "brain3.example.dev".into()), + ("accept".into(), "*/*".into()), + ], + ) + .await + .expect("asset GET should forward"); + + assert_eq!(response.status, 200); + assert_eq!(client.http_base_url(), "http://127.0.0.1:18420"); + let requests = proxy.requests.lock().expect("requests lock should succeed"); + assert_eq!(requests[3].method, "GET"); + assert_eq!( + requests[3].url, + "http://127.0.0.1:18420/mcp-use/widgets/practice-widget/assets/index.js?v=1" + ); + assert_eq!( + requests[3].headers, + vec![("accept".to_string(), "*/*".to_string())] + ); + } + #[tokio::test] async fn call_tool_strips_container_prefix_before_forwarding() { let proxy = Arc::new(CapturingProxy::with_responses(vec![ json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), json_response(json!({"jsonrpc": "2.0", "id": 2, "result": {"tools": []}})), + json_response(json!({"jsonrpc": "2.0", "id": 3, "result": {"resources": []}})), json_response(json!({ "jsonrpc": "2.0", "id": 7, @@ -390,12 +886,240 @@ mod tests { assert_eq!(response.status, 200); let requests = proxy.requests.lock().expect("requests lock should succeed"); let body: Value = - serde_json::from_slice(&requests[2].body).expect("forwarded body should be JSON"); + serde_json::from_slice(&requests[3].body).expect("forwarded body should be JSON"); assert_eq!(body["params"]["name"], "search_deck"); assert_eq!(body["params"]["arguments"]["query"], "rust"); - assert!(!requests[2] + assert!(!requests[3] .headers .iter() .any(|(name, _)| name.eq_ignore_ascii_case("authorization"))); } + + #[tokio::test] + async fn read_resource_prefixes_returned_resource_uri() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({"jsonrpc": "2.0", "id": 2, "result": {"tools": []}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 3, + "result": { + "resources": [ + { + "uri": "ui://widget-name/index.html", + "name": "Plugin widget", + "mimeType": "text/html" + } + ] + } + })), + json_response_with_content_length(json!({ + "jsonrpc": "2.0", + "id": 9, + "result": { + "contents": [ + { + "uri": "ui://widget-name/index.html", + "mimeType": "text/html", + "text": "

plugin
" + } + ] + } + })), + ])); + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy.clone(), + ) + .await + .expect("client should initialize"); + + let response = client + .read_resource( + &json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "resources/read", + "params": { + "uri": "ui://fluensy_learn__widget-name/index.html" + } + }), + "ui://widget-name/index.html", + ) + .await + .expect("resource read should forward"); + + assert_eq!(response.status, 200); + assert!(response + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("content-length"))); + let response_body: Value = + serde_json::from_slice(&response.body).expect("response body should be JSON"); + assert_eq!( + response_body["result"]["contents"][0]["uri"], + "ui://fluensy_learn__widget-name/index.html" + ); + + let requests = proxy.requests.lock().expect("requests lock should succeed"); + let forwarded_body: Value = + serde_json::from_slice(&requests[3].body).expect("forwarded body should be JSON"); + assert_eq!( + forwarded_body["params"]["uri"], + "ui://widget-name/index.html" + ); + } + + #[tokio::test] + async fn initialize_and_cache_tools_rewrites_matching_ui_resource_uri() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "search_deck", + "description": "Search deck", + "inputSchema": { "type": "object" }, + "_meta": { + "ui": { + "resourceUri": "ui://widget-name/index.html" + } + } + } + ] + } + })), + json_response(json!({ + "jsonrpc": "2.0", + "id": 3, + "result": { + "resources": [ + { + "uri": "ui://widget-name/index.html", + "name": "Plugin widget", + "mimeType": "text/html" + } + ] + } + })), + ])); + + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy, + ) + .await + .expect("client should initialize"); + + let resources = client.prefixed_resource_schemas(); + assert_eq!(resources.len(), 1); + assert_eq!( + resources[0]["uri"], + "ui://fluensy_learn__widget-name/index.html" + ); + + let tools = client.prefixed_tool_schemas(); + assert_eq!( + tools[0]["_meta"]["ui"]["resourceUri"], + "ui://fluensy_learn__widget-name/index.html" + ); + } + + #[tokio::test] + async fn initialize_and_cache_tools_leaves_unknown_ui_resource_uri_unchanged() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "search_deck", + "description": "Search deck", + "inputSchema": { "type": "object" }, + "_meta": { + "ui.resourceUri": "ui://other-widget/index.html" + } + } + ] + } + })), + json_response(json!({ + "jsonrpc": "2.0", + "id": 3, + "result": { + "resources": [ + { + "uri": "ui://widget-name/index.html", + "name": "Plugin widget", + "mimeType": "text/html" + } + ] + } + })), + ])); + + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy, + ) + .await + .expect("client should initialize"); + + let tools = client.prefixed_tool_schemas(); + assert_eq!( + tools[0]["_meta"]["ui.resourceUri"], + "ui://other-widget/index.html" + ); + } + + #[tokio::test] + async fn initialize_and_cache_tools_tolerates_resources_list_method_not_found() { + let proxy = Arc::new(CapturingProxy::with_responses(vec![ + json_response(json!({"jsonrpc": "2.0", "id": 1, "result": {}})), + json_response(json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "search_deck", + "description": "Search deck", + "inputSchema": { "type": "object" } + } + ] + } + })), + json_response(json!({ + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32601, + "message": "Method not found" + } + })), + ])); + + let client = RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:18420/mcp".into(), + None, + proxy, + ) + .await + .expect("client should initialize"); + + assert_eq!(client.prefixed_resource_schemas().len(), 0); + assert_eq!(client.prefixed_tool_schemas().len(), 1); + } } diff --git a/crates/core/src/domain/model.rs b/crates/core/src/domain/model.rs index bf72614..6645e34 100644 --- a/crates/core/src/domain/model.rs +++ b/crates/core/src/domain/model.rs @@ -86,6 +86,10 @@ pub struct PluginMcpContainerConfig { pub auth: PluginMcpContainerAuth, } +pub fn plugin_mcp_container_asset_mount(name: &str) -> String { + name.replace('_', "-") +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginMcpContainerAuth { None, diff --git a/crates/core/src/ports/mod.rs b/crates/core/src/ports/mod.rs index fdb945a..97aa6c9 100644 --- a/crates/core/src/ports/mod.rs +++ b/crates/core/src/ports/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod container; pub mod mcp_proxy; +pub mod native_mcp_resource; pub mod native_mcp_tool; pub mod setup_system; pub mod tunnel; diff --git a/crates/core/src/ports/native_mcp_resource.rs b/crates/core/src/ports/native_mcp_resource.rs new file mode 100644 index 0000000..e1fafea --- /dev/null +++ b/crates/core/src/ports/native_mcp_resource.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeMcpResourceContent { + Text(String), + Blob(String), +} + +impl NativeMcpResourceContent { + pub fn text(text: impl Into) -> Self { + Self::Text(text.into()) + } + + pub fn blob(blob: impl Into) -> Self { + Self::Blob(blob.into()) + } +} + +#[derive(Debug, Error)] +pub enum NativeMcpResourceError { + #[error("resource read failed: {0}")] + ReadFailed(String), +} + +#[async_trait::async_trait] +pub trait NativeMcpResource: Send + Sync { + fn uri(&self) -> &str; + fn name(&self) -> &str; + fn mime_type(&self) -> &str; + + async fn read(&self) -> Result; +} diff --git a/crates/platform/src/config/brain3_yaml.rs b/crates/platform/src/config/brain3_yaml.rs index 4136ec9..f196b2b 100644 --- a/crates/platform/src/config/brain3_yaml.rs +++ b/crates/platform/src/config/brain3_yaml.rs @@ -5,7 +5,8 @@ use std::fs; use std::path::{Path, PathBuf}; use brain3_core::domain::model::{ - ContainerRuntime, PluginMcpContainerAuth, PluginMcpContainerConfig, + plugin_mcp_container_asset_mount, ContainerRuntime, PluginMcpContainerAuth, + PluginMcpContainerConfig, }; use serde::de::{self, Visitor}; use serde::Deserialize; @@ -230,15 +231,22 @@ fn required_string(value: Option, field: &str) -> Result } fn validate_name(name: &str) -> Result<(), String> { - if !name.is_empty() - && name + if name.is_empty() + || !name .bytes() .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') { - return Ok(()); + return Err("name must match [a-z0-9_]+".to_string()); } - Err("name must match [a-z0-9_]+".to_string()) + let mount = plugin_mcp_container_asset_mount(name); + if matches!(mount.as_str(), "health" | "mcp" | "oauth") { + return Err(format!( + "name '{name}' maps to reserved gateway mount '{mount}'" + )); + } + + Ok(()) } fn validate_network_name(name: &str) -> Result<(), String> { @@ -571,6 +579,37 @@ plugin_mcp_containers: assert_eq!(configs[0].name, "good_name"); } + #[test] + fn reserved_gateway_mount_name_is_dropped() { + let temp = tempfile::tempdir().expect("tempdir"); + let reserved_dir = temp.path().join("reserved-data"); + let good_dir = temp.path().join("good-data"); + fs::create_dir_all(&reserved_dir).expect("reserved dir"); + fs::create_dir_all(&good_dir).expect("good dir"); + let reserved_secret = temp.path().join("reserved.token"); + let good_secret = temp.path().join("good.token"); + write_file(&reserved_secret, "reserved-secret"); + write_file(&good_secret, "good-secret"); + let config_path = temp.path().join("brain3.yaml"); + write_file( + &config_path, + &format!( + r#" +plugin_mcp_containers: +{} +{} +"#, + valid_entry("mcp", "reserved-net", &reserved_dir, &reserved_secret), + valid_entry("good_name", "good-name-net", &good_dir, &good_secret) + ), + ); + + let configs = load_plugin_mcp_containers_config(&config_path); + + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name, "good_name"); + } + #[test] fn env_map_loads_in_deterministic_key_order() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/platform/src/container/startup.rs b/crates/platform/src/container/startup.rs index 6a3528d..df3631b 100644 --- a/crates/platform/src/container/startup.rs +++ b/crates/platform/src/container/startup.rs @@ -5,11 +5,12 @@ use std::sync::Arc; use brain3_core::application::ensure_container::EnsureContainerUseCase; use brain3_core::domain::errors::ContainerError; use brain3_core::domain::model::{ - BindMount, ContainerConfig, ContainerLabel, ContainerNetworkIsolationStrategy, - ContainerRuntime, ContainerStartupConfig, ManagedContainerInfo, ManagedContainerScope, - PluginMcpContainerAuth, PluginMcpContainerConfig, PortMapping, - BRAIN3_INSTALLATION_ID_LABEL_KEY, BRAIN3_MANAGED_LABEL_KEY, BRAIN3_MANAGED_LABEL_VALUE, - BRAIN3_MCP_ROLE_LABEL_VALUE, BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX, BRAIN3_ROLE_LABEL_KEY, + plugin_mcp_container_asset_mount, BindMount, ContainerConfig, ContainerLabel, + ContainerNetworkIsolationStrategy, ContainerRuntime, ContainerStartupConfig, + ManagedContainerInfo, ManagedContainerScope, PluginMcpContainerAuth, PluginMcpContainerConfig, + PortMapping, BRAIN3_INSTALLATION_ID_LABEL_KEY, BRAIN3_MANAGED_LABEL_KEY, + BRAIN3_MANAGED_LABEL_VALUE, BRAIN3_MCP_ROLE_LABEL_VALUE, BRAIN3_PLUGIN_MCP_ROLE_LABEL_PREFIX, + BRAIN3_ROLE_LABEL_KEY, }; use brain3_core::domain::setup::RuntimeStartupPolicy; use brain3_core::ports::container::{ContainerId, ContainerPort}; @@ -104,6 +105,7 @@ pub async fn ensure_mcp_container( pub async fn ensure_plugin_mcp_container( plugin: &PluginMcpContainerConfig, + public_origin: &str, startup_policy: RuntimeStartupPolicy, installation_id: &str, ) -> Result { @@ -149,7 +151,7 @@ pub async fn ensure_plugin_mcp_container( ) .await?; - let config = build_plugin_container_config(plugin, host_port, installation_id); + let config = build_plugin_container_config(plugin, public_origin, host_port, installation_id); let (_id, container_ip) = EnsureContainerUseCase::new(port).ensure(&config).await?; Ok(StartedPluginMcpContainer { config: plugin.clone(), @@ -349,6 +351,7 @@ fn build_container_config( fn build_plugin_container_config( plugin: &PluginMcpContainerConfig, + public_origin: &str, host_port: u16, installation_id: &str, ) -> ContainerConfig { @@ -377,11 +380,23 @@ fn build_plugin_container_config( }); } + let mount = plugin_mcp_container_asset_mount(&plugin.name); + let public_base_url = format!("{}/{}", public_origin.trim_end_matches('/'), mount); + let mut env_vars: Vec<(String, String)> = plugin + .env + .iter() + .filter(|(key, _)| key != "PUBLIC_BASE_URL") + .cloned() + .collect(); + env_vars.push(("PUBLIC_BASE_URL".into(), public_base_url.clone())); + let isolation_strategy = plugin .network_isolation .then(|| plugin_isolation_strategy(plugin.runtime)); tracing::info!( container = %plugin.name, + mount = %mount, + public_base_url = %public_base_url, installation_id, network = %plugin.network_name, network_isolated = plugin.network_isolation, @@ -402,7 +417,7 @@ fn build_plugin_container_config( host_port, container_port: plugin.container_port, }], - env_vars: plugin.env.clone(), + env_vars, labels: managed_container_labels_for_role( installation_id, plugin_mcp_role_label(plugin.name.as_str()).as_str(), @@ -935,7 +950,12 @@ mod tests { #[test] fn build_plugin_container_config_adds_plugin_role_labels_and_mounts() { - let config = build_plugin_container_config(&sample_plugin_config(), 18420, "scope-1"); + let config = build_plugin_container_config( + &sample_plugin_config(), + "https://brain3.example.dev", + 18420, + "scope-1", + ); assert_eq!(config.name, "fluensy_learn"); assert_eq!(config.image, "ghcr.io/example/fluensy-learn:latest"); @@ -954,7 +974,13 @@ mod tests { assert_eq!(config.port_mappings[0].host_address, "127.0.0.1"); assert_eq!(config.port_mappings[0].host_port, 18420); assert_eq!(config.port_mappings[0].container_port, 8420); - assert_eq!(config.env_vars, Vec::<(String, String)>::new()); + assert_eq!( + config.env_vars, + vec![( + "PUBLIC_BASE_URL".to_string(), + "https://brain3.example.dev/fluensy-learn".to_string() + )] + ); assert_eq!( config.labels, vec![ @@ -999,7 +1025,8 @@ mod tests { let mut plugin = sample_plugin_config(); plugin.auth = PluginMcpContainerAuth::None; - let config = build_plugin_container_config(&plugin, 18420, "scope-1"); + let config = + build_plugin_container_config(&plugin, "https://brain3.example.dev", 18420, "scope-1"); assert_eq!(config.bind_mounts.len(), 1); assert_eq!( @@ -1015,11 +1042,44 @@ mod tests { let mut plugin = sample_plugin_config(); plugin.env = vec![("LOGFIRE_CONSOLE".into(), "true".into())]; - let config = build_plugin_container_config(&plugin, 18420, "scope-1"); + let config = + build_plugin_container_config(&plugin, "https://brain3.example.dev/", 18420, "scope-1"); + + assert_eq!( + config.env_vars, + vec![ + ("LOGFIRE_CONSOLE".to_string(), "true".to_string()), + ( + "PUBLIC_BASE_URL".to_string(), + "https://brain3.example.dev/fluensy-learn".to_string() + ), + ] + ); + } + + #[test] + fn build_plugin_container_config_overrides_stale_public_base_url_env_var() { + let mut plugin = sample_plugin_config(); + plugin.env = vec![ + ("MCP_URL".into(), "https://stale.example.dev".into()), + ("PUBLIC_BASE_URL".into(), "https://wrong.example.dev".into()), + ]; + + let config = + build_plugin_container_config(&plugin, "https://brain3.example.dev", 18420, "scope-1"); assert_eq!( config.env_vars, - vec![("LOGFIRE_CONSOLE".to_string(), "true".to_string())] + vec![ + ( + "MCP_URL".to_string(), + "https://stale.example.dev".to_string() + ), + ( + "PUBLIC_BASE_URL".to_string(), + "https://brain3.example.dev/fluensy-learn".to_string() + ), + ] ); } @@ -1083,7 +1143,8 @@ mod tests { let mut plugin = sample_plugin_config(); plugin.network_isolation = false; - let config = build_plugin_container_config(&plugin, 18420, "scope-1"); + let config = + build_plugin_container_config(&plugin, "https://brain3.example.dev", 18420, "scope-1"); assert_eq!(config.isolation_strategy, None); } @@ -1095,8 +1156,18 @@ mod tests { second_plugin.name = "other_plugin".into(); second_plugin.network_name = "other-plugin-net".into(); - let first = build_plugin_container_config(&first_plugin, 18420, "scope-1"); - let second = build_plugin_container_config(&second_plugin, 18421, "scope-1"); + let first = build_plugin_container_config( + &first_plugin, + "https://brain3.example.dev", + 18420, + "scope-1", + ); + let second = build_plugin_container_config( + &second_plugin, + "https://brain3.example.dev", + 18421, + "scope-1", + ); assert_eq!(first.network_name, "fluensy-learn-net"); assert_eq!(second.network_name, "other-plugin-net"); diff --git a/crates/platform/src/http/mcp_handlers.rs b/crates/platform/src/http/mcp_handlers.rs index 8d5274a..72f59a9 100644 --- a/crates/platform/src/http/mcp_handlers.rs +++ b/crates/platform/src/http/mcp_handlers.rs @@ -1,6 +1,6 @@ use axum::body::Bytes; use axum::extract::State; -use axum::http::{HeaderMap, Method, StatusCode, Uri}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; use axum::Json; use oxide_auth::primitives::issuer::Issuer; @@ -15,6 +15,13 @@ use brain3_core::ports::mcp_proxy::McpProxyPort; use super::state::AppState; +const ACCESS_CONTROL_ALLOW_ORIGIN: HeaderName = + HeaderName::from_static("access-control-allow-origin"); +const ACCESS_CONTROL_ALLOW_METHODS: HeaderName = + HeaderName::from_static("access-control-allow-methods"); +const ACCESS_CONTROL_ALLOW_HEADERS: HeaderName = + HeaderName::from_static("access-control-allow-headers"); + fn resolve_base_url(headers: &HeaderMap) -> String { let proto = headers .get("x-forwarded-proto") @@ -273,6 +280,116 @@ fn upstream_response_to_axum_response( } } +fn apply_plugin_asset_cors(headers: &mut HeaderMap) { + headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); + headers.insert( + ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, OPTIONS"), + ); + headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("*")); +} + +fn plugin_asset_path_parts(path: &str) -> Option<(&str, &str)> { + let rest = path.strip_prefix('/')?; + let (mount, stripped_path) = rest.split_once('/')?; + if mount.is_empty() || stripped_path.is_empty() { + return None; + } + Some((mount, stripped_path)) +} + +fn plugin_asset_response_with_cors(response: Response) -> Response { + let mut response = response; + apply_plugin_asset_cors(response.headers_mut()); + response +} + +pub async fn plugin_asset_options( + State(state): State>, + uri: Uri, +) -> Response { + let Some((mount, path)) = plugin_asset_path_parts(uri.path()) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let mount_known = state + .plugin_asset_mounts + .iter() + .any(|asset_mount| asset_mount.mount == mount); + if !mount_known || !path.starts_with("mcp-use/") { + return plugin_asset_response_with_cors(StatusCode::NOT_FOUND.into_response()); + } + + plugin_asset_response_with_cors(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn plugin_asset_proxy( + State(state): State>, + uri: Uri, + headers: HeaderMap, +) -> Response { + let Some((mount, path)) = plugin_asset_path_parts(uri.path()) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some(asset_mount) = state + .plugin_asset_mounts + .iter() + .find(|asset_mount| asset_mount.mount == mount) + else { + return plugin_asset_response_with_cors(StatusCode::NOT_FOUND.into_response()); + }; + if !path.starts_with("mcp-use/") { + tracing::warn!( + container = %asset_mount.client.container_name(), + mount, + path, + "rejecting Plugin MCP Container asset request outside mcp-use subtree" + ); + return plugin_asset_response_with_cors(StatusCode::NOT_FOUND.into_response()); + } + + let result = asset_mount + .client + .get_http_path(path, uri.query(), header_pairs(&headers)) + .await; + + let response = match result { + Ok(upstream_response) => { + let status = StatusCode::from_u16(upstream_response.status) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + tracing::info!( + container = %asset_mount.client.container_name(), + mount, + path, + upstream_status = status.as_u16(), + body_bytes = upstream_response.body.len(), + "proxying Plugin MCP Container asset" + ); + + let filtered_headers = + ProxyMcpUseCase::

::filter_response_headers(upstream_response.headers); + let mut response_builder = Response::builder().status(status); + for (name, value) in filtered_headers { + response_builder = response_builder.header(name.as_str(), value.as_str()); + } + response_builder + .body(axum::body::Body::from(upstream_response.body)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + Err(error) => { + tracing::warn!( + container = %asset_mount.client.container_name(), + mount, + path, + error = %error, + "Plugin MCP Container asset upstream unavailable" + ); + StatusCode::BAD_GATEWAY.into_response() + } + }; + + plugin_asset_response_with_cors(response) +} + pub async fn protected_resource_metadata( State(state): State>, headers: HeaderMap, diff --git a/crates/platform/src/http/router.rs b/crates/platform/src/http/router.rs index 85315c5..f43e29d 100644 --- a/crates/platform/src/http/router.rs +++ b/crates/platform/src/http/router.rs @@ -9,7 +9,10 @@ use brain3_core::ports::mcp_proxy::McpProxyPort; use super::assets::{login_logo, login_stylesheet}; use super::health::health; -use super::mcp_handlers::{local_mcp_proxy, mcp_reverse_proxy, protected_resource_metadata}; +use super::mcp_handlers::{ + local_mcp_proxy, mcp_reverse_proxy, plugin_asset_options, plugin_asset_proxy, + protected_resource_metadata, +}; use super::oauth_handlers::{ oauth_authorize_get, oauth_authorize_post, oauth_metadata, oauth_token, }; @@ -26,7 +29,7 @@ async fn fallback(req: Request) -> impl IntoResponse { } pub fn build_router(state: AppState

) -> Router { - Router::new() + let mut router = Router::new() .route("/health", get(health)) .route( "/.well-known/oauth-authorization-server", @@ -60,7 +63,23 @@ pub fn build_router(state: AppState

) -> Router { get(mcp_reverse_proxy::

) .post(mcp_reverse_proxy::

) .delete(mcp_reverse_proxy::

), - ) + ); + + for asset_mount in state.plugin_asset_mounts.iter() { + let route = format!("/{}/{{*path}}", asset_mount.mount); + tracing::info!( + mount = %asset_mount.mount, + container = %asset_mount.client.container_name(), + route = %route, + "registering Plugin MCP Container asset route" + ); + router = router.route( + &route, + get(plugin_asset_proxy::

).options(plugin_asset_options::

), + ); + } + + router .fallback(fallback) .layer(TraceLayer::new_for_http()) .with_state(state) diff --git a/crates/platform/src/http/state.rs b/crates/platform/src/http/state.rs index c86c7c7..b2b8f7e 100644 --- a/crates/platform/src/http/state.rs +++ b/crates/platform/src/http/state.rs @@ -6,6 +6,7 @@ use tokio::sync::Mutex; use crate::token_store::sqlite::SqliteTokenStore; use brain3_core::application::mcp_router::McpRouterUseCase; +use brain3_core::application::remote_mcp_container_client::RemoteMcpContainerClient; use brain3_core::domain::model::GatewayConfig; use brain3_core::ports::mcp_proxy::McpProxyPort; @@ -19,6 +20,12 @@ pub struct AppState { pub proxy_mcp: Arc>, pub config: Arc, pub rate_limiter: Arc, + pub plugin_asset_mounts: Arc>>, +} + +pub struct PluginAssetMount { + pub mount: String, + pub client: Arc>, } impl Clone for AppState

{ @@ -30,6 +37,7 @@ impl Clone for AppState

{ proxy_mcp: Arc::clone(&self.proxy_mcp), config: Arc::clone(&self.config), rate_limiter: Arc::clone(&self.rate_limiter), + plugin_asset_mounts: Arc::clone(&self.plugin_asset_mounts), } } } diff --git a/crates/platform/src/runtime/bootstrap.rs b/crates/platform/src/runtime/bootstrap.rs index 536f069..e50cb11 100644 --- a/crates/platform/src/runtime/bootstrap.rs +++ b/crates/platform/src/runtime/bootstrap.rs @@ -308,20 +308,6 @@ pub async fn bootstrap_configured_runtime( container_status }; - let plugin_mcp_containers = if container_status.allows_gateway_start() { - start_plugin_mcp_containers( - &launch_plan.paths.app_home, - startup_policy, - installation_id.as_str(), - ) - .await - } else { - tracing::debug!( - container_status = ?container_status, - "skipping Plugin MCP Containers because core MCP container is not ready" - ); - Vec::new() - }; let pid_file = launch_plan.paths.app_home.join("cloudflared.pid"); let (tunnel_status, public_url, tunnel_guard) = if !container_status.allows_gateway_start() { @@ -358,6 +344,26 @@ pub async fn bootstrap_configured_runtime( (StartupStatus::NotConfigured, None, None) }; + let gateway_public_origin = public_url + .clone() + .or_else(|| configured_public_origin(&config)) + .unwrap_or_else(|| format!("http://127.0.0.1:{}", config.port)); + let plugin_mcp_containers = if container_status.allows_gateway_start() { + start_plugin_mcp_containers( + &launch_plan.paths.app_home, + gateway_public_origin.as_str(), + startup_policy, + installation_id.as_str(), + ) + .await + } else { + tracing::debug!( + container_status = ?container_status, + "skipping Plugin MCP Containers because core MCP container is not ready" + ); + Vec::new() + }; + Ok(RuntimeBootstrap { config, upstream_secret, @@ -373,6 +379,7 @@ pub async fn bootstrap_configured_runtime( async fn start_plugin_mcp_containers( app_home: &std::path::Path, + public_origin: &str, startup_policy: RuntimeStartupPolicy, installation_id: &str, ) -> Vec { @@ -394,10 +401,13 @@ async fn start_plugin_mcp_containers( let mut started = Vec::new(); for config in configs { - match ensure_plugin_mcp_container(&config, startup_policy, installation_id).await { + match ensure_plugin_mcp_container(&config, public_origin, startup_policy, installation_id) + .await + { Ok(container) => { tracing::info!( container = %container.config.name, + public_origin = %public_origin, host_port = container.host_port, container_ip = ?container.container_ip, "Plugin MCP Container ready" @@ -416,6 +426,22 @@ async fn start_plugin_mcp_containers( started } +fn configured_public_origin(config: &GatewayConfig) -> Option { + match &config.tunnel { + Some(TunnelConfig::CloudflareNamed { + tunnel_name, + domain, + .. + }) => Some(format!("https://{tunnel_name}.{domain}")), + Some(TunnelConfig::CloudflareQuick { .. }) => None, + None => config + .hostname_validation + .expected_host + .as_ref() + .map(|host| format!("https://{host}")), + } +} + fn log_plugin_container_startup_failure(config: &PluginMcpContainerConfig, error: &ContainerError) { let summary = error.summary(); if let Some(logs) = error.recent_logs() { diff --git a/crates/platform/tests/oauth_integration.rs b/crates/platform/tests/oauth_integration.rs index 0e43e76..83978bd 100644 --- a/crates/platform/tests/oauth_integration.rs +++ b/crates/platform/tests/oauth_integration.rs @@ -13,16 +13,17 @@ use tokio::sync::Mutex; use brain3_core::application::mcp_router::McpRouterUseCase; use brain3_core::application::native_mcp_tool_registry::NativeMcpToolRegistry; use brain3_core::application::proxy_mcp::ProxyMcpUseCase; +use brain3_core::application::remote_mcp_container_client::RemoteMcpContainerClient; use brain3_core::domain::errors::ProxyError; use brain3_core::domain::model::{ - AccessMode, GatewayConfig, HostnameValidationConfig, LocalMcpConfig, MCPReverseProxyConfig, - NativeAudioTranscriptionConfig, OAuthConfig, + plugin_mcp_container_asset_mount, AccessMode, GatewayConfig, HostnameValidationConfig, + LocalMcpConfig, MCPReverseProxyConfig, NativeAudioTranscriptionConfig, OAuthConfig, }; use brain3_core::ports::mcp_proxy::{McpProxyPort, McpProxyRequest, McpProxyResponse}; use brain3_platform::http::registrar::GatewayRegistrar; use brain3_platform::http::router::{build_local_router, build_router}; -use brain3_platform::http::state::AppState; +use brain3_platform::http::state::{AppState, PluginAssetMount}; use brain3_platform::token_store::sqlite::SqliteTokenStore; const CLIENT_ID: &str = "brain3-oauth2-client"; @@ -200,6 +201,7 @@ impl TestHarness { proxy_mcp, config, rate_limiter: Arc::new(brain3_platform::http::rate_limit::OAuthRateLimiter::new()), + plugin_asset_mounts: Arc::new(Vec::new()), }; BuiltServer { @@ -266,6 +268,7 @@ impl TestHarness { proxy_mcp, config, rate_limiter: Arc::new(brain3_platform::http::rate_limit::OAuthRateLimiter::new()), + plugin_asset_mounts: Arc::new(Vec::new()), }; BuiltServer { @@ -275,6 +278,85 @@ impl TestHarness { } } +async fn build_server_with_plugin_asset_mount(proxy: Arc) -> TestServer { + let registrar = Arc::new(GatewayRegistrar::new( + CLIENT_ID, + CLIENT_SECRET.as_bytes().to_vec(), + )); + let authorizer = Arc::new(Mutex::new(AuthMap::new(RandomGenerator::new(32)))); + let issuer = Arc::new(Mutex::new( + SqliteTokenStore::in_memory(3600, 90 * 24 * 60 * 60) + .expect("in-memory issuer should initialize"), + )); + let proxy_mcp = Arc::new(ProxyMcpUseCase::new( + Arc::clone(&proxy), + "http://127.0.0.1:2765".into(), + "shared-secret".into(), + HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + )); + let proxy_mcp = empty_native_router(proxy_mcp); + let config = Arc::new(GatewayConfig { + port: 0, + host: "127.0.0.1".into(), + token_db_path: "/tmp/brain3-test-brain3.db".into(), + oauth: OAuthConfig { + client_id: CLIENT_ID.into(), + client_secret: CLIENT_SECRET.into(), + access_token_lifetime_secs: 3600, + refresh_token_lifetime_secs: 90 * 24 * 60 * 60, + pkce_required: true, + username: LOGIN_USERNAME.into(), + password: LOGIN_PASSWORD.into(), + }, + mcp_reverse_proxy: MCPReverseProxyConfig { + mcp_upstream_url: "http://127.0.0.1:2765".into(), + upstream_secret: "shared-secret".into(), + }, + hostname_validation: HostnameValidationConfig { + expected_host: None, + enforce: true, + }, + access_mode: AccessMode::Both, + local_mcp: None, + container: None, + tunnel: None, + native_audio_transcription: NativeAudioTranscriptionConfig { + enabled: false, + model: "base.en".into(), + model_path: "/tmp/brain3-whisper-models/ggml-base.en.bin".into(), + max_audio_bytes: 52_428_800, + }, + }); + + let client = Arc::new( + RemoteMcpContainerClient::initialize_and_cache_tools( + "fluensy_learn".into(), + "http://127.0.0.1:59020/mcp".into(), + None, + Arc::clone(&proxy), + ) + .await + .expect("plugin client should initialize"), + ); + let state = AppState { + registrar, + authorizer, + issuer, + proxy_mcp, + config, + rate_limiter: Arc::new(brain3_platform::http::rate_limit::OAuthRateLimiter::new()), + plugin_asset_mounts: Arc::new(vec![PluginAssetMount { + mount: plugin_mcp_container_asset_mount(client.container_name()), + client, + }]), + }; + + TestServer::new(build_router(state)) +} + fn authorize_form() -> Vec<(&'static str, &'static str)> { vec![ ("response_type", "code"), @@ -408,6 +490,161 @@ async fn call_local_mcp(server: &TestServer, token: Option<&str>) -> axum_test:: request.await } +fn plugin_asset_mock(captured: Arc>>) -> MockMcpProxy { + MockMcpProxy { + handler: Box::new(move |request| { + captured.lock().unwrap().push(McpProxyRequest { + method: request.method.clone(), + url: request.url.clone(), + headers: request.headers.clone(), + body: request.body.clone(), + }); + + if request.method == "GET" { + return Ok(McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/javascript".into())], + body: b"console.log('widget');".to_vec(), + }); + } + + let body: Value = serde_json::from_slice(&request.body) + .expect("plugin init request body should be JSON"); + let method = body + .get("method") + .and_then(Value::as_str) + .expect("plugin init request should include method"); + let result = match method { + "initialize" => serde_json::json!({"capabilities": {}}), + "tools/list" => serde_json::json!({"tools": []}), + "resources/list" => serde_json::json!({"resources": []}), + other => panic!("unexpected Plugin MCP init method: {other}"), + }; + Ok(McpProxyResponse { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": result, + })) + .unwrap(), + }) + }), + } +} + +#[tokio::test] +async fn plugin_asset_get_proxies_to_mounted_container_with_cors() { + let captured = Arc::new(std::sync::Mutex::new(Vec::new())); + let proxy = Arc::new(plugin_asset_mock(Arc::clone(&captured))); + let server = build_server_with_plugin_asset_mount(proxy).await; + + let response = server + .get("/fluensy-learn/mcp-use/widgets/practice-widget/assets/index.js") + .add_query_param("v", "abc123") + .add_header( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer browser-token"), + ) + .await; + + response.assert_status_ok(); + assert_eq!(response.text(), "console.log('widget');"); + assert_eq!( + response + .header("content-type") + .to_str() + .expect("content-type header should be valid"), + "application/javascript" + ); + assert_eq!( + response + .header("access-control-allow-origin") + .to_str() + .expect("CORS header should be valid"), + "*" + ); + + let requests = captured.lock().unwrap(); + let asset_request = requests.last().expect("asset request should be captured"); + assert_eq!(asset_request.method, "GET"); + assert_eq!( + asset_request.url, + "http://127.0.0.1:59020/mcp-use/widgets/practice-widget/assets/index.js?v=abc123" + ); + assert!(asset_request + .headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("authorization") + && !name.eq_ignore_ascii_case("host") + && !name.eq_ignore_ascii_case("content-length"))); +} + +#[tokio::test] +async fn plugin_asset_options_returns_preflight_cors() { + let captured = Arc::new(std::sync::Mutex::new(Vec::new())); + let proxy = Arc::new(plugin_asset_mock(Arc::clone(&captured))); + let server = build_server_with_plugin_asset_mount(proxy).await; + + let response = server + .method( + axum::http::Method::OPTIONS, + "/fluensy-learn/mcp-use/widgets/practice-widget/assets/index.css", + ) + .await; + + response.assert_status(axum::http::StatusCode::NO_CONTENT); + assert_eq!( + response + .header("access-control-allow-methods") + .to_str() + .expect("CORS methods header should be valid"), + "GET, OPTIONS" + ); + let requests = captured.lock().unwrap(); + assert_eq!(requests.len(), 3, "preflight should not call upstream"); +} + +#[tokio::test] +async fn plugin_asset_route_rejects_paths_outside_mcp_use_subtree() { + let captured = Arc::new(std::sync::Mutex::new(Vec::new())); + let proxy = Arc::new(plugin_asset_mock(Arc::clone(&captured))); + let server = build_server_with_plugin_asset_mount(proxy).await; + + let response = server.get("/fluensy-learn/mcp").await; + + response.assert_status_not_found(); + assert_eq!( + response + .header("access-control-allow-origin") + .to_str() + .expect("CORS header should be valid"), + "*" + ); + let requests = captured.lock().unwrap(); + assert_eq!(requests.len(), 3, "rejected path should not call upstream"); +} + +#[tokio::test] +async fn local_router_does_not_expose_plugin_asset_mounts() { + let built = TestHarness { + local_mcp: Some(LocalMcpConfig { + port: 2764, + bearer_token: "local-token".into(), + }), + ..Default::default() + } + .build_local_server(MockMcpProxy::should_not_be_called()); + + let response = built + .server + .get("/fluensy-learn/mcp-use/widgets/practice-widget/assets/index.js") + .await; + + response.assert_status_not_found(); +} + #[tokio::test] async fn authorize_token_and_mcp_flow_succeeds() { let built = TestHarness::default().build_server(MockMcpProxy::success()); diff --git a/docs/superpowers/plans/2026-07-15-fix-resource-read-uri-prefix.md b/docs/superpowers/plans/2026-07-15-fix-resource-read-uri-prefix.md new file mode 100644 index 0000000..96d1db1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-fix-resource-read-uri-prefix.md @@ -0,0 +1,164 @@ +# RCA + Fix Plan: Plugin MCP widget UI not appearing after resource-proxying work + +## Summary + +The `resources/read` round trip for Plugin MCP Container widgets is silently +broken: the gateway correctly requests the resource from the plugin +container, correctly gets a 200 back, but returns the plugin's response +**unmodified** — including its own un-prefixed `uri` field — to the host, +instead of rewriting it to match the prefixed URI the host actually asked +for. Hosts that verify the returned resource's `uri` matches what they +requested (a reasonable thing for any MCP Apps host to do, since it's how a +confused-deputy attack would be prevented) will discard the response, and +the widget never renders — even though every other hop in the chain is +working. + +This was found by cross-referencing `/Users/tleyden/.brain3/brain3.log` +against the code, not by any purpose-built tooling — see "How I verified +propagation" below for a reusable checklist, and "Diagnosability gaps" for +what to add so this doesn't require manual log archaeology next time. + +## Evidence trail (from `/Users/tleyden/.brain3/brain3.log`, run starting 22:11:39) + +1. `fluensy_learn` container started and initialized cleanly: + `cached Plugin MCP Container tool schemas container=fluensy_learn tool_count=5 resource_count=1` (line 104). +2. `tools/list` correctly merged in all 5 prefixed plugin tools + 1 native + tool (line 134, `total_tool_count=16`). +3. `resources/list` correctly merged in the 1 plugin resource (line 151, + `plugin_resource_count=1 total_resource_count=1`). +4. The host (`brain3-macos.mcpnative.dev`) then actually called + `resources/read` for `ui://fluensy_learn__widget/practice-widget.html` — + i.e. it saw the widget, recognized the (correctly prefixed) resourceUri, + and asked for it (line 166). This proves `_meta.ui.resourceUri` rewriting + in `RemoteMcpContainerClient::prefix_tool_schemas` / + `rewrite_tool_resource_uri` is working. +5. The gateway correctly stripped the prefix and forwarded to the plugin + container as `ui://widget/practice-widget.html` (line 167), and got back + HTTP 200 / 1087 bytes (line 169, "MCP upstream responded OK"). +6. **But**: `RemoteMcpContainerClient::read_resource()` + (`crates/core/src/application/remote_mcp_container_client.rs:146-173`) + forwards the *request* with the URI rewritten back to the plugin's + original form (correct), then returns the plugin's raw JSON-RPC response + completely unmodified via `self.forward_json(body).await` (no rewriting + at all on the way out). The plugin's response body's + `result.contents[0].uri` therefore still says + `ui://widget/practice-widget.html` — the plugin's own un-prefixed URI — + not `ui://fluensy_learn__widget/practice-widget.html`, which is what the + host asked for and is the only URI the host knows how to look up a widget + by. + +This is the mirror-image gap of `call_tool()`, which *does* rewrite the +outbound request's tool `name` back to unprefixed before forwarding, but +(correctly, since tool call results don't echo the tool name) doesn't need +to rewrite anything in the response. `read_resource()` needs the same +treatment `call_tool()` gets on the way in, plus a rewrite on the way out +that `call_tool()` doesn't need. + +Also confirmed as *not* the problem, despite initially looking suspicious: + +- The Phase 4 `initialize` capability patch + (`patch_initialize_resources_capability` in `mcp_router.rs:513-556`) never + logs "added resources capability to initialize response" in this run. This + looked like a bug but isn't: vault-tools is built on + `mcp.server.fastmcp.FastMCP`, which unconditionally registers a + `list_resources` handler in `_setup_handlers()` + (`mcp/server/fastmcp/server.py:309`) regardless of whether any resources + are actually registered. That means vault-tools' own `initialize` response + already advertises `"resources": {"subscribe": false, "listChanged": false}` + today, so the `capabilities.contains_key("resources")` early-return in our + patch fires every time — silently, since that branch has no log line (see + diagnosability gap #1 below). The capability is present either way; this + is not why the widget fails to render. + +## Fix + +In `crates/core/src/application/remote_mcp_container_client.rs`: + +- `read_resource()`: after `forward_json` returns, parse the response body + as JSON. If it has a `result.contents` array, rewrite each element's `uri` + field from this container's `original_uri` back to `prefixed_uri` (reuse + `prefix_resource_uri_for_container`, already defined at module scope) — + only rewrite entries whose `uri` matches this resource's `original_uri`, + leave anything else untouched. Re-serialize and strip `content-length`, + mirroring the exact `append_result_array` / `strip_content_length` pattern + already used in `mcp_router.rs`. If the body is a JSON-RPC error or fails + to parse, pass it through unmodified (same tolerant style as + `patch_initialize_resources_capability`). +- Add a unit test mirroring `call_tool_strips_container_prefix_before_forwarding`, + but asserting the *response* body's `result.contents[0].uri` comes back as + `ui://fluensy_learn__widget-name/index.html`, not the plugin's raw + `ui://widget-name/index.html`. +- Extend the existing router-level test + `resources_read_for_prefixed_plugin_resource_routes_to_container_and_strips_prefix` + in `mcp_router.rs` to assert on `response.body`'s returned `uri`, not just + the outbound request — today it only checks what was sent to the plugin, + never what comes back to the host, which is exactly how this bug slipped + through review. + +## Diagnosability gaps to close (the "no way to debug this" problem) + +These didn't cause the bug, but they're why confirming/denying it required +manually correlating six different log lines by timestamp instead of reading +one clear signal: + +1. `patch_initialize_resources_capability` (`mcp_router.rs:513-556`) has + three silent no-op return paths (`!has_resources()`, response already has + an `error`, capabilities already contains `resources`) but only two of + the four total branches log anything. Add a `tracing::debug!` on every + branch stating *why* no patch was applied (or that none was needed), + so you can tell from logs alone whether the `resources` capability came + from brain3's patch or was already present upstream. +2. `rewrite_tool_resource_uri()` (`remote_mcp_container_client.rs:375-396`) + only logs on the failure branch (URI not found in resources/list). Add an + `tracing::info!` on success with container, tool name, and + before/after resourceUri — today success is only inferable indirectly + (by the *absence* of a warning, or by watching what URI the host later + requests). +3. Neither `call_tool()` nor `read_resource()` log the plugin container's + response status/body length at the point they receive it — only the + outbound forward is logged in `remote_mcp_container_client.rs`, and the + generic "MCP upstream responded OK" line in `mcp_handlers.rs` doesn't + distinguish plugin-routed traffic from vault-tools traffic. Add a + `tracing::debug!` in both functions logging status + body length of the + plugin's raw response, before any router-level rewriting happens, so a + "plugin container gave us garbage" failure mode is distinguishable from a + "we mangled a good response" failure mode. + +## How I verified propagation end-to-end (reusable checklist) + +Since there's no purpose-built tool for this yet, this is the manual +procedure that established the facts above — worth turning into a script if +this comes up again: + +1. Confirm the plugin container declared the resource in the first place: + `grep "cached Plugin MCP Container tool schemas" brain3.log` — check + `resource_count` > 0. +2. Confirm `tools/list` includes the tool with a **prefixed** + `_meta.ui.resourceUri` — currently only checkable by grepping for + `"MCP router: appended native and Plugin MCP tools"` (confirms the merge + happened) plus manually calling `tools/list` and inspecting the JSON, since + there's no dedicated log line for the URI rewrite itself (gap #2 above). +3. Confirm `resources/list` includes the same prefixed URI: + `grep "appended native and Plugin MCP resources" brain3.log`. +4. Confirm the **host** actually calls `resources/read` with that exact + prefixed URI (not the plugin's raw URI) — if it doesn't, the widget was + never recognized as belonging to that tool in the first place, which + points back at step 2. `grep "routing Plugin MCP resource read" brain3.log`. +5. Confirm the plugin container returns 200 for that read: + `grep "forwarding resources/read to Plugin MCP Container" brain3.log`, + then find the matching "MCP upstream responded OK" line by timestamp. +6. **This is the step that was missing and hid the bug**: confirm the `uri` + field *inside* the response body that goes back to the host matches the + prefixed URI from step 4, not the plugin's raw URI. There is currently no + log line for this — you have to reproduce the call by hand (e.g. drive + the local MCP listener or the plugin container directly with `curl`, feed + it through the same code path, and inspect the JSON) or add a temporary + log statement. Once the fix above ships, this becomes checkable purely + from logs if diagnosability gap #3 is also closed. + +## Verification after fix + +- `cargo test -p brain3 --no-run` then `cargo test`. +- Manually re-run the host flow against the real `fluensy_learn` container + and confirm the widget renders, or at minimum re-run step 6 above and + confirm the returned `uri` is now prefixed. diff --git a/docs/superpowers/plans/2026-07-15-proxy-mcp-resources.md b/docs/superpowers/plans/2026-07-15-proxy-mcp-resources.md new file mode 100644 index 0000000..88be12b --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-proxy-mcp-resources.md @@ -0,0 +1,181 @@ +# Proxy MCP Resources (App Widget support) + +## Goal + +Today the gateway's `McpRouterUseCase` special-cases three JSON-RPC methods — +`initialize`, `tools/list`, `tools/call` — so it can merge in native (in-process +Rust) tools and Plugin MCP Container tools alongside the core vault-tools +container's own tools. Everything else, including `resources/list` and +`resources/read`, falls through to a generic catch-all that forwards +byte-for-byte to the core vault-tools container (`ProxyMcpUseCase::forward_request` +in `crates/core/src/application/proxy_mcp.rs`). + +That catch-all is enough for **MCP App Widgets** (per the pasted host +integration notes: the host does `resources/read` for a tool's +`_meta.ui.resourceUri`, plus `tools/call` from the widget itself) *only when +the widget's resource lives on the core vault-tools container*. It silently +breaks for: + +- **Plugin MCP Containers** (`RemoteMcpContainerClient`) — if a plugin + container declares a tool with `_meta.ui.resourceUri`, the host's + `resources/read` for that `ui://` URI falls into the catch-all and gets sent + to the *core vault-tools container*, not the plugin container that actually + owns the resource. Vault-tools won't recognize the URI and will 404/error. +- **Native Rust tools** (`NativeMcpToolRegistry`) — there is no resource store + for native tools at all today. A native tool that wants an App Widget has + nowhere to register the HTML/JS bundle, and any `resources/read` for it would + also incorrectly hit vault-tools. + +This plan extends the existing tools/list-merge, tools/call-route pattern to +`resources/list` and `resources/read`, plus patches the `initialize` response +so hosts learn resources are supported at all. + +## Non-goals + +- No resource *subscriptions* (`resources/subscribe`, `notifications/resources/updated`). + Nothing in today's codebase or the pasted host notes needs them. +- No native tool actually gets an App Widget in this plan — this is + infrastructure only. (A follow-up plan would add the first native + `NativeMcpResource` impl once there's a widget to ship.) +- Not touching vault-tools (Python) — it doesn't declare a `resources` + capability today and isn't in scope; whatever it does or doesn't expose + keeps flowing through the existing untouched catch-all. +- No SECURITY_AUDIT.MD update needed: this reuses the exact same trust + boundary and bearer-secret mechanism already used for `tools/call` proxying + to Plugin MCP Containers — it's a new JSON-RPC *method* being routed, not a + new network ingress. + +## Key design decision: resource URI prefixing + +Tool names from Plugin MCP Containers are already namespaced with a +`{container_name}__{original_name}` prefix (see +`RemoteMcpContainerClient::fetch_prefixed_tool_schemas`) so two containers +can't collide and so `tools/call` can be routed back to the right container by +stripping the prefix. Resource URIs need the same treatment, but a URI has +structure (`scheme://authority/path`) that a bare tool name doesn't, so the +prefix goes into the **authority** component, not the whole string: + +``` +original: ui://widget-name/index.html +prefixed: ui://fluensy_learn__widget-name/index.html +``` + +This keeps the prefixed URI a syntactically valid URI (same scheme, authority +still a legal token), and is reversible: strip `resources/read` params to +`{container}__{rest}`, split on the first `__`, forward `rest` unprefixed to +the plugin container. + +Consequence: any `_meta.ui.resourceUri` inside a Plugin MCP Container's tool +schema must be rewritten with the same prefix when we rewrite the tool's +`name` field, otherwise the host will ask for a URI our router doesn't +recognize as belonging to that container. + +Native tool resource URIs are **not** prefixed — native tools already aren't +namespaced (`NativeMcpToolRegistry` matches on bare `name()`), so a native +tool's `ui://` URI is expected to already be globally unique by convention +(e.g. `ui://brain3-native//index.html`). + +## Phases + +### Phase 1 — `ports::native_mcp_resource` + registry support + +- New port trait `NativeMcpResource` (mirrors `NativeMcpTool`): + ```rust + #[async_trait::async_trait] + pub trait NativeMcpResource: Send + Sync { + fn uri(&self) -> &str; + fn name(&self) -> &str; + fn mime_type(&self) -> &str; + async fn read(&self) -> Result; + } + ``` +- `NativeMcpToolRegistry` gains an optional `Vec>` + (or a sibling `NativeMcpResourceRegistry`, matching the existing one-registry- + per-concept style), with `find_resource(uri)` and `list_resource_schemas()` + (for `resources/list` descriptor entries: `uri`, `name`, `mimeType`). +- No concrete resource yet in this phase — this is scaffolding, unit-tested + with a fake resource the same way `FakeNativeTool` is used in + `mcp_router.rs` tests today. + +### Phase 2 — `RemoteMcpContainerClient` resource caching + +- Extend `RemoteMcpContainerClient::initialize_and_cache_tools` to also call + `resources/list` right after `tools/list`. If the plugin container returns + a JSON-RPC error (e.g. `-32601 Method not found` because it never declared + a `resources` capability), treat that as "no resources" and continue — + don't fail container initialization over it. Log at `debug`/`info` either + way (mirrors the tolerant, best-effort tone of the rest of this file). +- Cache `Vec`, + same shape as `RemoteMcpContainerToolSchema`. +- Add `strip_resource_uri_prefix(uri) -> Option<&str>` and + `has_resource(original_uri) -> bool`, mirroring `strip_prefix`/`has_tool`. +- Add `read_resource(request, original_uri) -> Result` + that clones the incoming `resources/read` request, rewrites + `params.uri` back to `original_uri`, and forwards it — mirrors `call_tool`. +- In `fetch_prefixed_tool_schemas`, after prefixing `name`, also rewrite + `_meta.ui.resourceUri` in place if present and if it matches one of this + container's own resource URIs (look it up in the just-fetched resource + list; if it doesn't match anything the container declared, leave it alone + and log a warning — a widget pointing at a resource its own server didn't + list is a plugin bug, not ours to silently paper over). + +### Phase 3 — `McpRouterUseCase` routing + +In `crates/core/src/application/mcp_router.rs`, add two new match arms in +`route_request` alongside the existing `tools/list` / `tools/call` ones: + +- **`resources/list`**: forward to the core proxy first (unchanged upstream + call), then append native resource schemas and each plugin container's + cached prefixed resource schemas — same pattern as `append_tool_schemas`. + Extract the shared "parse body, mutate `result.`, re-serialize, + strip content-length" logic into a small helper both `tools/list` and + `resources/list` call, rather than copy-pasting the whole function body. +- **`resources/read`**: parse `params.uri` from the request body. + 1. If it matches a native resource, serve it locally (analogous to + `maybe_call_native_tool` — build the JSON-RPC `result.contents[]` + response directly, no proxy round-trip). + 2. Else, for each plugin container, check `strip_resource_uri_prefix`; if + it matches, call `read_resource` on that container and return its + response (analogous to `maybe_call_plugin_tool`). + 3. Else, fall through to the core proxy unchanged (today's behavior) — this + is the path that keeps vault-tools' own resources working exactly as + they do now. + +### Phase 4 — `initialize` response capability patch + +Hosts likely gate whether they ever attempt `resources/read` on the server +advertising a `resources` capability in its `initialize` response. Today +`mcp_router.rs`'s `initialize` arm forwards the core proxy's response +untouched. Patch it: if native resources or any plugin container resources +exist, and the parsed response's `result.capabilities` doesn't already have a +`resources` key, insert `"resources": {}`. Same "parse, mutate, re-serialize, +strip content-length" helper as Phase 3. + +This behavior is a best guess at what real MCP Apps hosts (ChatGPT, Claude) +require — flag it for a quick manual check against a real host once there's +an actual widget to test with, since we have no host simulator in this repo. + +### Phase 5 — tests + +Follow the existing style in `mcp_router.rs`'s `#[cfg(test)] mod tests` +(`CapturingProxy`, `FakeNativeTool`, `initialized_plugin_client` helpers): + +- `resources_list_forwards_to_proxy_and_appends_native_and_plugin_resources` +- `resources_read_for_native_resource_bypasses_proxy` +- `resources_read_for_prefixed_plugin_resource_routes_to_container_and_strips_prefix` +- `resources_read_falls_through_to_core_proxy_for_unrecognized_uri` +- `initialize_response_gains_resources_capability_when_resources_exist` +- In `remote_mcp_container_client.rs`: a test asserting `_meta.ui.resourceUri` + gets rewritten with the container prefix when it matches a cached resource, + and left alone (with a warning, not a failure) when it doesn't. +- In `remote_mcp_container_client.rs`: a test asserting a plugin container + that returns `-32601` for `resources/list` still initializes successfully + with zero cached resources. + +## Verification + +- `cargo test -p brain3 --no-run` then `cargo test` after each phase. +- No E2E fixture today ships a `ui://` resource, so `e2e_smoke.rs` isn't + expected to change — note this gap rather than fabricate an E2E test around + it; a real widget-bearing plugin container would be the natural trigger to + add one later. diff --git a/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md b/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md new file mode 100644 index 0000000..33824ec --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md @@ -0,0 +1,260 @@ +# Proxy plugin MCP widget static assets through the gateway + +Status: DRAFT — awaiting review +Date: 2026-07-18 + +## Problem + +The Fluensy Learn plugin container renders an MCP UI widget (`practice-widget`). +Through Brain3, the drill tool call and the widget **HTML** resource load fine, +but the widget paints blank because its JS/CSS bundle fails to load. + +Observed in a live ChatGPT session (host = "Brain3 MacOS Fluensy"): + +Browser console: +``` +Access to script at +'https://brain3-macos.mcpnative.dev/mcp-use/widgets/practice-widget/assets/index-T4KqNWzc.js' +... has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header ... +index-T4KqNWzc.js: Failed to load resource: net::ERR_FAILED +index-DyhWf25U.css: Failed to load resource: the server responded with a status of 404 +``` + +Gateway log (repeats on every drill): +``` +WARN brain3_platform::http::router: no matching route — returning 404 + method=GET path=/mcp-use/widgets/practice-widget/assets/index-T4KqNWzc.js + host=Some("brain3-macos.mcpnative.dev") +``` + +## Who generates the asset URL + +The URL is generated by the **`mcp-use` server library inside the plugin +container**, not by Brain3 or the MCP host. Two pieces combine +(`node_modules/mcp-use/dist/chunk-2M2JEBVY.js`, `processWidgetHtml()`): + +1. The `/mcp-use/widgets//…` path prefix is **hardcoded in mcp-use**. It + rewrites the widget's built HTML: `src="/mcp-use/widgets/$1"` → + `src="${baseUrl}/mcp-use/widgets/$1"`. `` = `slugifyWidgetName(name)` + from the tool's `widget: { name: "practice-widget" }`; `/assets/index-*.js|css` + comes from the widget's own Vite build. +2. `baseUrl` = the `MCPServer({ baseUrl })` option = `mcpBaseUrl` = + `process.env.PUBLIC_BASE_URL` (`index.ts:37`), currently + `https://brain3-macos.mcpnative.dev`. + +Result: `{PUBLIC_BASE_URL}/mcp-use/widgets/{slug}/assets/index-*.js`. The `/mcp-use` +prefix is fixed, but **`PUBLIC_BASE_URL` is ours to set** — mcp-use builds the asset URL +by string concatenation, so a path suffix on `PUBLIC_BASE_URL` flows through to the +asset URLs. This is the lever the design below uses. + +## Root cause + +The widget shell HTML (served via `resources/read`) references its bundle at +**absolute URLs on the public origin**, because the container bakes `PUBLIC_BASE_URL` +(`https://brain3-macos.mcpnative.dev`) into the asset/base URLs: + +``` +https://brain3-macos.mcpnative.dev/mcp-use/widgets/practice-widget/assets/index-*.js +https://brain3-macos.mcpnative.dev/mcp-use/widgets/practice-widget/assets/index-*.css +``` + +The plugin container **does** serve these assets (verified: `GET :3000/mcp-use/ +widgets/practice-widget/assets/index-*.js` → 200). But the Brain3 gateway +router (`crates/platform/src/http/router.rs`) only has routes for `/health`, +OAuth paths, and `/mcp[/...]`. There is **no route for these asset paths**, so +the fallback returns 404. Because nothing handles the route, there is also no +`Access-Control-Allow-Origin` header, which is the CORS error above. The 404 +and the CORS failure are the same missing-route bug. + +## Why this is different from normal MCP routing + +Normal MCP routing is **body-based RPC over one authenticated endpoint**: +everything is `POST /mcp` with a JSON body, and Brain3 routes by inspecting the +payload, namespaced by container name — `params.name = +"fluensy_learn__show_practice_drill"` → strip `fluensy_learn__` → route to that +container; `params.uri = "ui://fluensy_learn__widget/…"` likewise. One ingress, +closed, bearer-auth, container name embedded in the request. + +Widget assets are **not MCP**. After the host renders the widget HTML in its +sandbox, the *browser* makes ordinary `