[MCP-UI] Proxy and Better Message Handling (#5487)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
@@ -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" {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
let secret_key = request
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn run() -> Result<()> {
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = crate::routes::configure(app_state)
|
||||
let app = crate::routes::configure(app_state, secret_key.clone())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
secret_key.clone(),
|
||||
check_token,
|
||||
|
||||
@@ -326,6 +326,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
paths(
|
||||
super::routes::status::status,
|
||||
super::routes::status::diagnostics,
|
||||
super::routes::mcp_ui_proxy::mcp_ui_proxy,
|
||||
super::routes::config_management::backup_config,
|
||||
super::routes::config_management::recover_config,
|
||||
super::routes::config_management::validate_config,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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"),
|
||||
(
|
||||
header::HeaderName::from_static("referrer-policy"),
|
||||
"no-referrer",
|
||||
),
|
||||
],
|
||||
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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="referrer" content="no-referrer"/>
|
||||
<!--
|
||||
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>
|
||||
Reference in New Issue
Block a user