From 3968fe8453391a9b16deb86b8b985b01312478b7 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Wed, 15 Jul 2026 16:36:32 +0100 Subject: [PATCH 1/8] plan to fix --- .../plans/2026-07-15-proxy-mcp-resources.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-proxy-mcp-resources.md 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. From 2edaf31454c4078a20a36d07eb6a35a44e257f66 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Wed, 15 Jul 2026 22:48:57 +0100 Subject: [PATCH 2/8] implement plan to proxy resources --- crates/core/src/application/mcp_router.rs | 868 +++++++++++++++++- .../application/native_mcp_tool_registry.rs | 38 +- .../remote_mcp_container_client.rs | 416 ++++++++- crates/core/src/ports/mod.rs | 1 + crates/core/src/ports/native_mcp_resource.rs | 32 + 5 files changed, 1296 insertions(+), 59 deletions(-) create mode 100644 crates/core/src/ports/native_mcp_resource.rs diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index de5bc2c..1028928 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,142 @@ 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() { + 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() { + 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") { + 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 +661,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 +725,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 +790,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 +826,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 +858,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,6 +934,38 @@ mod tests { } } + 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, ) -> ( @@ -566,13 +973,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 { @@ -584,11 +987,80 @@ mod tests { 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 +1071,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 +1090,7 @@ mod tests { Arc::new(registry), vec![plugin_client], ), - captured, + Arc::clone(&proxy.captured), tool, plugin_captured, ) @@ -634,11 +1102,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 +1319,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 +1386,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 +1476,281 @@ 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"); + } + + #[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..8df14f3 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 }) } @@ -68,6 +81,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 +101,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, @@ -113,6 +143,35 @@ impl RemoteMcpContainerClient

{ self.forward_json(body).await } + 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" + ); + + self.forward_json(body).await + } + async fn initialize(&self) -> Result<(), ProxyError> { let response = self .forward_json( @@ -139,9 +198,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 +233,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 +265,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 +274,127 @@ 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; + }; + + set_tool_resource_uri(schema, &resource_schema.prefixed_uri); + } + async fn forward_json(&self, body: Vec) -> Result { let mut headers = vec![ ("content-type".into(), "application/json".into()), @@ -257,6 +441,71 @@ 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()), + ); + } + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -323,6 +572,7 @@ mod tests { ] } })), + json_response(json!({"jsonrpc": "2.0", "id": 3, "result": {"resources": []}})), ])); let client = RemoteMcpContainerClient::initialize_and_cache_tools( @@ -340,7 +590,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 @@ -354,6 +604,7 @@ mod tests { 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 +641,163 @@ 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 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/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; +} From 9268b2cc24b6abbfb1a985fe6e46e6281834a3fc Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Wed, 15 Jul 2026 23:42:17 +0100 Subject: [PATCH 3/8] RCA + Fix Plan: Plugin MCP widget UI not appearing after resource-proxying work --- ...2026-07-15-fix-resource-read-uri-prefix.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-fix-resource-read-uri-prefix.md 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. From 235449e6066b48c6d84f0fc82ca2b2da88eb98b5 Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Wed, 15 Jul 2026 23:48:09 +0100 Subject: [PATCH 4/8] implement plan to fix resource proxying. --- crates/core/src/application/mcp_router.rs | 28 +++ .../remote_mcp_container_client.rs | 212 +++++++++++++++++- 2 files changed, 238 insertions(+), 2 deletions(-) diff --git a/crates/core/src/application/mcp_router.rs b/crates/core/src/application/mcp_router.rs index 1028928..1e8a37d 100644 --- a/crates/core/src/application/mcp_router.rs +++ b/crates/core/src/application/mcp_router.rs @@ -515,6 +515,9 @@ impl McpRouterUseCase

{ response: McpProxyResponse, ) -> McpProxyResponse { if !self.has_resources() { + tracing::debug!( + "MCP router: initialize response resources capability patch skipped; no resources are registered" + ); return response; } @@ -523,6 +526,9 @@ impl McpRouterUseCase

{ 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; } @@ -537,6 +543,9 @@ impl McpRouterUseCase

{ }; 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!({})); @@ -1669,6 +1678,25 @@ mod tests { 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] diff --git a/crates/core/src/application/remote_mcp_container_client.rs b/crates/core/src/application/remote_mcp_container_client.rs index 8df14f3..d73cb8a 100644 --- a/crates/core/src/application/remote_mcp_container_client.rs +++ b/crates/core/src/application/remote_mcp_container_client.rs @@ -140,7 +140,17 @@ 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( @@ -169,7 +179,17 @@ impl RemoteMcpContainerClient

{ "forwarding resources/read to Plugin MCP Container" ); - self.forward_json(body).await + 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)) } async fn initialize(&self) -> Result<(), ProxyError> { @@ -392,9 +412,101 @@ impl RemoteMcpContainerClient

{ 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()), @@ -506,6 +618,13 @@ fn set_tool_resource_uri(schema: &mut Value, resource_uri: &str) { } } +fn strip_content_length(headers: Vec<(String, String)>) -> Vec<(String, String)> { + headers + .into_iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case("content-length")) + .collect() +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -555,6 +674,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![ @@ -650,6 +781,83 @@ mod tests { .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![ From 8482885fd4c12862391dec4a9f38db8f2b496a8f Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Sat, 18 Jul 2026 08:58:55 +0100 Subject: [PATCH 5/8] plan: Proxy plugin MCP widget static assets through the gateway --- .../2026-07-18-proxy-plugin-widget-assets.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md 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..8803e1d --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md @@ -0,0 +1,172 @@ +# 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") +``` + +## Root cause + +The widget shell HTML (served via `resources/read`) references its bundle at +**absolute URLs on the public origin**, because the container bakes `MCP_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 `/mcp-use/**`**, 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. + +Verified reachability: + +| Path | Container (`:59020`) | Gateway (`:2763`) | +| --- | --- | --- | +| `/mcp-use/widgets/practice-widget/assets/index-*.js` | 200 | 404 | +| `/mcp-use/widgets/practice-widget/assets/index-*.css` | 200 | 404 | + +Note: the plugin container itself, the drill generation, and the DB write/save +path are all healthy — this is purely a gateway static-asset routing gap. + +## Goal + +Serve plugin widget static assets (`/mcp-use/widgets/**`) through the gateway by +reverse-proxying GET requests to the owning plugin container, with CORS headers +so the MCP host's widget sandbox can load them. + +Out of scope for now (explicitly deferred): making Fluensy emit relative asset +URLs. The browser resolves the asset URLs against the public gateway origin +regardless, so the gateway proxy is the real fix. + +## Design + +### New route + +In `build_router` (`crates/platform/src/http/router.rs`), add a GET route: + +``` +/mcp-use/{*path} -> plugin_widget_asset_proxy +``` + +A new handler in `mcp_handlers.rs` (e.g. `plugin_widget_asset_proxy`) that: +1. Resolves which plugin container owns the request (see "Container resolution"). +2. Reverse-proxies `GET {container_http_base}/mcp-use/{path}` (query string + preserved) to the container. +3. Returns the container's response body + `Content-Type`, adding CORS headers. + +Only expose GET (and OPTIONS for preflight). Do **not** forward POST/DELETE or +any non-`/mcp-use/` path — keep the unauthenticated surface minimal. + +### Container resolution + +The asset path (`/mcp-use/widgets/practice-widget/assets/...`) carries **no** +`container__` prefix, unlike tool names and resource URIs, so the owner is not +directly encoded in the path. Options: + +- **(Preferred) Map the widget-name path segment to the owning container.** + The 2nd path segment (`practice-widget`) is the widget name. Brain3 already + knows each container's widget resource URIs (it rewrote + `ui://widget/practice-widget.html` → `ui://fluensy_learn__widget/ + practice-widget.html`). Build a lookup: widget-name → container from the + cached `prefixed_resource_schemas()` on each `RemoteMcpContainerClient`, and + proxy to that container's HTTP base. Robust with multiple plugins. +- **(Fallback) Single-plugin shortcut.** If exactly one plugin container is + configured, proxy `/mcp-use/**` straight to it. Simpler, but breaks the moment + a second plugin registers a widget. Acceptable only as an interim step. + +Recommend implementing the widget-name → container map; fall back to the single +container if the segment can't be matched. + +### Container HTTP base + +`RemoteMcpContainerClient` holds `mcp_url` (e.g. `http://127.0.0.1:59020/mcp`). +Derive the HTTP base by stripping the trailing `/mcp`. Note the host port is +dynamic (was `55607`, now `59020`) — always read it from the client, never +hardcode. Expose a small accessor (e.g. `http_base_url()`) on the client. + +### CORS (host-agnostic) + +These are public, credential-free static bundles. Do **not** hardcode any +specific host's sandbox origin (e.g. no OpenAI `*.oaiusercontent.com`). Set: + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, OPTIONS +Access-Control-Allow-Headers: * +``` + +Respond to `OPTIONS /mcp-use/**` with 204 + the same headers for preflight. +Using `*` avoids coupling Brain3 to any particular MCP client. Revisit only if +we ever need credentialed asset requests (then reflect the `Origin` instead). + +## Implementation steps + +1. `remote_mcp_container_client.rs`: add `http_base_url()` (strip `/mcp` from + `mcp_url`) and a helper to list this container's widget names from its cached + resource schemas. +2. `mcp_router.rs` / core: expose a resolver `container_for_widget(widget_name) + -> Option<&RemoteMcpContainerClient>` (walk `plugin_containers`). +3. `mcp_handlers.rs`: add `plugin_widget_asset_proxy` — parse widget name from + path, resolve container, reverse-proxy GET via the existing reqwest proxy + layer, attach CORS headers, handle OPTIONS. +4. `router.rs`: register `GET/OPTIONS /mcp-use/{*path}` → new handler in + `build_router` (public origin only; the local router does not need it). +5. Logging: info-level "proxying plugin widget asset" with `container`, + `widget`, `path`, upstream status; keep noise reasonable. + +## Security + +Adding an unauthenticated ingress path requires updating the Threat Model in +`SECURITY_AUDIT.md` before merge (per AGENTS.MD). Constraints to document: +- Route is GET/OPTIONS only, scoped strictly to `/mcp-use/**`. +- Proxies only to already-managed plugin container HTTP bases (no arbitrary + upstream; path is not user-controllable beyond the asset subtree). +- Serves static assets the container already exposes; no auth bypass to `/mcp` + or vault data. `Access-Control-Allow-Origin: *` applies to these static + assets only, not to the authenticated `/mcp` endpoint. + +## Testing + +- Unit: widget-name → container resolution (hit, miss, multi-container). +- Unit/integration: `GET /mcp-use/widgets//assets/` returns 200 + with body, correct `Content-Type`, and `Access-Control-Allow-Origin: *`; + `OPTIONS` returns 204 with CORS headers; unknown widget → 404. +- Manual E2E: reload tools in the MCP host, run a Fluensy drill, confirm the + widget paints and the console shows no 404/CORS errors for `/mcp-use/**`. + +## Open questions + +- Multiple plugins exposing a widget of the **same name** — is widget-name alone + a safe key, or should we prefix asset paths per-container too? (Rare today; + single-plugin setup. Flag if multi-plugin widget collisions become real.) +- Do any MCP hosts require `Access-Control-Allow-Origin` to reflect a specific + Origin rather than `*` (e.g. for credentialed fetches)? Assumed no for static + bundles. From 815d413f8e5fd8a428ba45b92f2b95b99348a46e Mon Sep 17 00:00:00 2001 From: Traun Leyden Date: Sat, 18 Jul 2026 09:25:07 +0100 Subject: [PATCH 6/8] mcp url mount --- .../2026-07-18-proxy-plugin-widget-assets.md | 194 ++++++++++++------ 1 file changed, 132 insertions(+), 62 deletions(-) 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 index 8803e1d..72dbbd5 100644 --- a/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md +++ b/docs/superpowers/plans/2026-07-18-proxy-plugin-widget-assets.md @@ -27,6 +27,26 @@ WARN brain3_platform::http::router: no matching route — returning 404 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.MCP_URL` (`index.ts:35`), currently + `https://brain3-macos.mcpnative.dev`. + +Result: `{MCP_URL}/mcp-use/widgets/{slug}/assets/index-*.js`. The `/mcp-use` +prefix is fixed, but **`MCP_URL` is ours to set** — mcp-use builds the asset URL +by string concatenation, so a path suffix on `MCP_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 @@ -41,11 +61,29 @@ https://brain3-macos.mcpnative.dev/mcp-use/widgets/practice-widget/assets/index- 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 `/mcp-use/**`**, so the -fallback returns 404. Because nothing handles the route, there is also no +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 `