-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat: MCP UI proxy to goose-server #5749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,7 +10,7 @@ pub async fn check_token( | |||||
| request: Request, | ||||||
| next: Next, | ||||||
| ) -> Result<Response, StatusCode> { | ||||||
| if request.uri().path() == "/status" { | ||||||
| if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" { | ||||||
|
||||||
| if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" { | |
| if request.uri().path() == "/status" { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| use axum::{ | ||
| extract::Query, | ||
| http::{header, StatusCode}, | ||
| response::{Html, IntoResponse, Response}, | ||
| routing::get, | ||
| Router, | ||
| }; | ||
| use serde::Deserialize; | ||
|
|
||
| #[derive(Deserialize)] | ||
| struct ProxyQuery { | ||
| secret: String, | ||
| } | ||
|
|
||
| const MCP_UI_PROXY_HTML: &str = include_str!("templates/mcp_ui_proxy.html"); | ||
|
|
||
| #[utoipa::path( | ||
| get, | ||
| path = "/mcp-ui-proxy", | ||
| params( | ||
| ("secret" = String, Query, description = "Secret key for authentication") | ||
| ), | ||
| responses( | ||
| (status = 200, description = "MCP UI proxy HTML page", content_type = "text/html"), | ||
| (status = 401, description = "Unauthorized - invalid or missing secret"), | ||
| ) | ||
| )] | ||
| async fn mcp_ui_proxy( | ||
| axum::extract::State(secret_key): axum::extract::State<String>, | ||
| Query(params): Query<ProxyQuery>, | ||
| ) -> Response { | ||
| if params.secret != secret_key { | ||
|
||
| return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); | ||
| } | ||
|
|
||
| ( | ||
| [(header::CONTENT_TYPE, "text/html; charset=utf-8")], | ||
| Html(MCP_UI_PROXY_HTML), | ||
| ) | ||
| .into_response() | ||
| } | ||
|
|
||
| pub fn routes(secret_key: String) -> Router { | ||
| Router::new() | ||
| .route("/mcp-ui-proxy", get(mcp_ui_proxy)) | ||
| .with_state(secret_key) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ pub mod agent; | |
| pub mod audio; | ||
| pub mod config_management; | ||
| pub mod errors; | ||
| pub mod mcp_ui_proxy; | ||
|
||
| pub mod recipe; | ||
| pub mod recipe_utils; | ||
| pub mod reply; | ||
|
|
@@ -16,7 +17,7 @@ use std::sync::Arc; | |
| use axum::Router; | ||
|
|
||
| // Function to configure all routes | ||
| pub fn configure(state: Arc<crate::state::AppState>) -> Router { | ||
| pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Router { | ||
| Router::new() | ||
| .merge(status::routes()) | ||
| .merge(reply::routes(state.clone())) | ||
|
|
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router { | |
| .merge(session::routes(state.clone())) | ||
| .merge(schedule::routes(state.clone())) | ||
| .merge(setup::routes(state.clone())) | ||
| .merge(mcp_ui_proxy::routes(secret_key)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"/> | ||
| <!-- | ||
| Permissive CSP so nested content is not constrained by top-level Goose Desktop CSP | ||
| - default-src: Fallback for other directives (allows same-origin) | ||
| - script-src: Allow scripts from any origin, inline, eval, wasm, and blob URLs | ||
| - style-src: Allow styles from any origin and inline styles | ||
| - font-src: Allow fonts from any origin | ||
| - connect-src: Allow network requests to any origin | ||
| - frame-src: Allow embedding iframes from any origin (required for proxy functionality) | ||
| - media-src: Allow audio/video media from any origin | ||
| - base-uri: Restrict <base> tag to same-origin only | ||
| - upgrade-insecure-requests: Automatically upgrade HTTP to HTTPS | ||
| --> | ||
| <meta | ||
| http-equiv="Content-Security-Policy" | ||
| content="default-src 'self'; script-src * 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob:; style-src * 'unsafe-inline'; font-src *; connect-src *; frame-src *; media-src *; base-uri 'self'; upgrade-insecure-requests"/> | ||
| <title>MCP-UI Proxy</title> | ||
| <style> | ||
| body, | ||
| html { | ||
| margin: 0; | ||
| height: 100vh; | ||
| width: 100vw; | ||
| } | ||
| body { | ||
| display: flex; | ||
| flex-direction: column; | ||
| } | ||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
| iframe { | ||
| background-color: transparent; | ||
| border: 0 none transparent; | ||
| padding: 0; | ||
| overflow: hidden; | ||
| flex-grow: 1; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <script> | ||
| const params = new URLSearchParams(location.search); | ||
| const contentType = params.get('contentType'); | ||
| const target = params.get('url'); | ||
|
|
||
| // Validate that the URL is a valid HTTP or HTTPS URL | ||
| function isValidHttpUrl(string) { | ||
| try { | ||
| const url = new URL(string); | ||
| return url.protocol === 'http:' || url.protocol === 'https:'; | ||
| } catch (error) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| if (contentType === 'rawhtml') { | ||
| // Double-iframe raw HTML mode (HTML sent via postMessage) | ||
| const inner = document.createElement('iframe'); | ||
| inner.style = 'width:100%; height:100%; border:none;'; | ||
| // sandbox will be set from postMessage payload; default minimal before html arrives | ||
| inner.setAttribute('sandbox', 'allow-scripts'); | ||
| document | ||
| .body | ||
| .appendChild(inner); | ||
|
|
||
| // Wait for HTML content from parent | ||
| window.addEventListener('message', (event) => { | ||
| if (event.source === window.parent) { | ||
| if (event.data && event.data.type === 'ui-html-content') { | ||
| const payload = event.data.payload || {}; | ||
| const html = payload.html; | ||
| const sandbox = payload.sandbox; | ||
| if (typeof sandbox === 'string') { | ||
| inner.setAttribute('sandbox', sandbox); | ||
| } | ||
| if (typeof html === 'string') { | ||
| inner.srcdoc = html; | ||
| } | ||
| } else { | ||
| if (inner && inner.contentWindow) { | ||
| inner | ||
| .contentWindow | ||
| .postMessage(event.data, '*'); | ||
| } | ||
| } | ||
| } else if (event.source === inner.contentWindow) { | ||
| // Relay messages from inner to parent | ||
| window | ||
| .parent | ||
| .postMessage(event.data, '*'); | ||
| } | ||
| }); | ||
|
|
||
| // Notify parent that proxy is ready to receive HTML (distinct event) | ||
| window | ||
| .parent | ||
| .postMessage({ | ||
| type: 'ui-proxy-iframe-ready' | ||
| }, '*'); | ||
| } else if (target) { | ||
| if (!isValidHttpUrl(target)) { | ||
| document.body.textContent = 'Error: invalid URL. Only HTTP and HTTPS URLs are allowed.'; | ||
| } else { | ||
| const inner = document.createElement('iframe'); | ||
| inner.src = target; | ||
| inner.style = 'width:100%; height:100%; border:none;'; | ||
| // Default external URL sandbox; can be adjusted later by protocol if needed | ||
| inner.setAttribute('sandbox', 'allow-same-origin allow-scripts'); | ||
| document | ||
| .body | ||
| .appendChild(inner); | ||
| const urlOrigin = new URL(target).origin; | ||
|
|
||
| window.addEventListener('message', (event) => { | ||
| if (event.source === window.parent) { | ||
| // listen for messages from the parent and send them to the iframe | ||
| if (inner.contentWindow) { | ||
| inner | ||
| .contentWindow | ||
| .postMessage(event.data, urlOrigin); | ||
| } else { | ||
| console.warn('[MCP-UI Proxy] iframe contentWindow is not available; message not sent'); | ||
| } | ||
| } else if (event.source === inner.contentWindow) { | ||
| // listen for messages from the iframe and send them to the parent | ||
| window | ||
| .parent | ||
| .postMessage(event.data, '*'); | ||
| } | ||
| }); | ||
| } | ||
| } else { | ||
| document.body.textContent = 'Error: missing url or html parameter'; | ||
| } | ||
| </script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -98,14 +98,14 @@ export default function MCPUIResourceRenderer({ | |||||
| const theme = localStorage.getItem('theme') || 'light'; | ||||||
| setCurrentThemeValue(theme); | ||||||
|
|
||||||
| // Fetch the MCP-UI proxy URL from the main process | ||||||
| const fetchProxyUrl = async () => { | ||||||
| try { | ||||||
| const url = await window.electron.getMcpUIProxyUrl(); | ||||||
| if (url) { | ||||||
| setProxyUrl(url); | ||||||
| const baseUrl = await window.electron.getGoosedHostPort(); | ||||||
| const secretKey = await window.electron.getSecretKey(); | ||||||
| if (baseUrl && secretKey) { | ||||||
| setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`); | ||||||
|
||||||
| setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`); | |
| setProxyUrl(`${baseUrl}/mcp-ui-proxy`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bypassing authentication for /mcp-ui-proxy removes the security protections from the original implementation (token validation, origin checks, WebContents whitelisting). This allows unauthenticated access to MCP UI resources. Consider requiring the X-Secret-Key header or document why this endpoint must be public.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I support simplifying the complexity of security protections in the Node implementation. I think our goal should be to prevent access to the route outside of Goose (like directly in-browser and/or via CURL).