[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,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> 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);
|
return Ok(next.run(request).await);
|
||||||
}
|
}
|
||||||
let secret_key = request
|
let secret_key = request
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ pub async fn run() -> Result<()> {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(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(
|
.layer(middleware::from_fn_with_state(
|
||||||
secret_key.clone(),
|
secret_key.clone(),
|
||||||
check_token,
|
check_token,
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
paths(
|
paths(
|
||||||
super::routes::status::status,
|
super::routes::status::status,
|
||||||
super::routes::status::diagnostics,
|
super::routes::status::diagnostics,
|
||||||
|
super::routes::mcp_ui_proxy::mcp_ui_proxy,
|
||||||
super::routes::config_management::backup_config,
|
super::routes::config_management::backup_config,
|
||||||
super::routes::config_management::recover_config,
|
super::routes::config_management::recover_config,
|
||||||
super::routes::config_management::validate_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 audio;
|
||||||
pub mod config_management;
|
pub mod config_management;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod mcp_ui_proxy;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
pub mod recipe_utils;
|
pub mod recipe_utils;
|
||||||
pub mod reply;
|
pub mod reply;
|
||||||
@@ -16,7 +17,7 @@ use std::sync::Arc;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
// Function to configure all routes
|
// 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()
|
Router::new()
|
||||||
.merge(status::routes())
|
.merge(status::routes())
|
||||||
.merge(reply::routes(state.clone()))
|
.merge(reply::routes(state.clone()))
|
||||||
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
|||||||
.merge(session::routes(state.clone()))
|
.merge(session::routes(state.clone()))
|
||||||
.merge(schedule::routes(state.clone()))
|
.merge(schedule::routes(state.clone()))
|
||||||
.merge(setup::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>
|
||||||
@@ -1042,6 +1042,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/mcp-ui-proxy": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"super::routes::mcp_ui_proxy"
|
||||||
|
],
|
||||||
|
"operationId": "mcp_ui_proxy",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "secret",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Secret key for authentication",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "MCP UI proxy HTML page"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized - invalid or missing secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/recipes/create": {
|
"/recipes/create": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Generated
+4
-4
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.52",
|
"@ai-sdk/openai": "^2.0.52",
|
||||||
"@ai-sdk/ui-utils": "^1.2.11",
|
"@ai-sdk/ui-utils": "^1.2.11",
|
||||||
"@mcp-ui/client": "^5.13.0",
|
"@mcp-ui/client": "^5.14.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -3018,9 +3018,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mcp-ui/client": {
|
"node_modules/@mcp-ui/client": {
|
||||||
"version": "5.13.1",
|
"version": "5.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.14.1.tgz",
|
||||||
"integrity": "sha512-U0+kgdgmBRfqVM1MbqDZnaxBWboyDWQNmYrS72loL+XL9ZMtej5B7EThrPsE3wtwJftLURVagYqgDAnfSgKhXw==",
|
"integrity": "sha512-DHJ4H01L2oIiMdDzUrBErxYoli9Q3cQq5sXk3hhBQNqASbc55PtEhz6k0pOp7ykkj63MfxDKDmYXLw5jseY7/g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "*",
|
"@modelcontextprotocol/sdk": "*",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.52",
|
"@ai-sdk/openai": "^2.0.52",
|
||||||
"@ai-sdk/ui-utils": "^1.2.11",
|
"@ai-sdk/ui-utils": "^1.2.11",
|
||||||
"@mcp-ui/client": "^5.13.0",
|
"@mcp-ui/client": "^5.14.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -310,6 +310,13 @@ export const startTetrateSetup = <ThrowOnError extends boolean = false>(options?
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mcpUiProxy = <ThrowOnError extends boolean = false>(options: Options<McpUiProxyData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).get<McpUiProxyResponses, McpUiProxyErrors, ThrowOnError>({
|
||||||
|
url: '/mcp-ui-proxy',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
|
export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).post<CreateRecipeResponses, CreateRecipeErrors, ThrowOnError>({
|
return (options.client ?? client).post<CreateRecipeResponses, CreateRecipeErrors, ThrowOnError>({
|
||||||
url: '/recipes/create',
|
url: '/recipes/create',
|
||||||
|
|||||||
@@ -1725,6 +1725,32 @@ export type StartTetrateSetupResponses = {
|
|||||||
|
|
||||||
export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses];
|
export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses];
|
||||||
|
|
||||||
|
export type McpUiProxyData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Secret key for authentication
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
url: '/mcp-ui-proxy';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpUiProxyErrors = {
|
||||||
|
/**
|
||||||
|
* Unauthorized - invalid or missing secret
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpUiProxyResponses = {
|
||||||
|
/**
|
||||||
|
* MCP UI proxy HTML page
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateRecipeData = {
|
export type CreateRecipeData = {
|
||||||
body: CreateRecipeRequest;
|
body: CreateRecipeRequest;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
UIActionResultNotification,
|
UIActionResultNotification,
|
||||||
UIActionResultPrompt,
|
UIActionResultPrompt,
|
||||||
UIActionResultToolCall,
|
UIActionResultToolCall,
|
||||||
|
UIActionResult,
|
||||||
} from '@mcp-ui/client';
|
} from '@mcp-ui/client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -15,41 +16,6 @@ interface MCPUIResourceRendererProps {
|
|||||||
appendPromptToChat?: (value: string) => void;
|
appendPromptToChat?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UISizeChange = {
|
|
||||||
type: 'ui-size-change';
|
|
||||||
payload: {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reserved message types from iframe to host
|
|
||||||
type UILifecycleIframeReady = {
|
|
||||||
type: 'ui-lifecycle-iframe-ready';
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UIRequestData = {
|
|
||||||
type: 'ui-request-data';
|
|
||||||
messageId: string;
|
|
||||||
payload: {
|
|
||||||
requestType: string;
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// We are creating a new type to support all reserved message types that may come from the iframe
|
|
||||||
// Not all reserved message types are currently exported by @mcp-ui/client
|
|
||||||
type ActionEventsFromIframe =
|
|
||||||
| UIActionResultIntent
|
|
||||||
| UIActionResultLink
|
|
||||||
| UIActionResultNotification
|
|
||||||
| UIActionResultPrompt
|
|
||||||
| UIActionResultToolCall
|
|
||||||
| UISizeChange
|
|
||||||
| UILifecycleIframeReady
|
|
||||||
| UIRequestData;
|
|
||||||
|
|
||||||
// More specific result types using discriminated unions
|
// More specific result types using discriminated unions
|
||||||
type UIActionHandlerSuccess<T = unknown> = {
|
type UIActionHandlerSuccess<T = unknown> = {
|
||||||
status: 'success';
|
status: 'success';
|
||||||
@@ -126,20 +92,34 @@ export default function MCPUIResourceRenderer({
|
|||||||
appendPromptToChat,
|
appendPromptToChat,
|
||||||
}: MCPUIResourceRendererProps) {
|
}: MCPUIResourceRendererProps) {
|
||||||
const [currentThemeValue, setCurrentThemeValue] = useState<string>('light');
|
const [currentThemeValue, setCurrentThemeValue] = useState<string>('light');
|
||||||
|
const [proxyUrl, setProxyUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const theme = localStorage.getItem('theme') || 'light';
|
const theme = localStorage.getItem('theme') || 'light';
|
||||||
setCurrentThemeValue(theme);
|
setCurrentThemeValue(theme);
|
||||||
console.log('[MCP-UI] Current theme value:', theme);
|
|
||||||
|
const fetchProxyUrl = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = await window.electron.getGoosedHostPort();
|
||||||
|
const secretKey = await window.electron.getSecretKey();
|
||||||
|
if (baseUrl && secretKey) {
|
||||||
|
setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to get goosed host/port or secret key');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching MCP-UI Proxy URL:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProxyUrl().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUIAction = async (
|
const handleUIAction = async (actionEvent: UIActionResult): Promise<UIActionHandlerResult> => {
|
||||||
actionEvent: ActionEventsFromIframe
|
|
||||||
): Promise<UIActionHandlerResult> => {
|
|
||||||
// result to pass back to the MCP-UI
|
// result to pass back to the MCP-UI
|
||||||
let result: UIActionHandlerResult;
|
let result: UIActionHandlerResult;
|
||||||
|
|
||||||
const handleToolCase = async (
|
const handleToolAction = async (
|
||||||
actionEvent: UIActionResultToolCall
|
actionEvent: UIActionResultToolCall
|
||||||
): Promise<UIActionHandlerResult> => {
|
): Promise<UIActionHandlerResult> => {
|
||||||
const { toolName, params } = actionEvent.payload;
|
const { toolName, params } = actionEvent.payload;
|
||||||
@@ -156,7 +136,7 @@ export default function MCPUIResourceRenderer({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePromptCase = async (
|
const handlePromptAction = async (
|
||||||
actionEvent: UIActionResultPrompt
|
actionEvent: UIActionResultPrompt
|
||||||
): Promise<UIActionHandlerResult> => {
|
): Promise<UIActionHandlerResult> => {
|
||||||
const { prompt } = actionEvent.payload;
|
const { prompt } = actionEvent.payload;
|
||||||
@@ -191,7 +171,9 @@ export default function MCPUIResourceRenderer({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkCase = async (actionEvent: UIActionResultLink) => {
|
const handleLinkAction = async (
|
||||||
|
actionEvent: UIActionResultLink
|
||||||
|
): Promise<UIActionHandlerResult> => {
|
||||||
const { url } = actionEvent.payload;
|
const { url } = actionEvent.payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -244,7 +226,7 @@ export default function MCPUIResourceRenderer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotifyCase = async (
|
const handleNotifyAction = async (
|
||||||
actionEvent: UIActionResultNotification
|
actionEvent: UIActionResultNotification
|
||||||
): Promise<UIActionHandlerResult> => {
|
): Promise<UIActionHandlerResult> => {
|
||||||
const { message } = actionEvent.payload;
|
const { message } = actionEvent.payload;
|
||||||
@@ -262,7 +244,7 @@ export default function MCPUIResourceRenderer({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIntentCase = async (
|
const handleIntentAction = async (
|
||||||
actionEvent: UIActionResultIntent
|
actionEvent: UIActionResultIntent
|
||||||
): Promise<UIActionHandlerResult> => {
|
): Promise<UIActionHandlerResult> => {
|
||||||
toast.info(
|
toast.info(
|
||||||
@@ -285,82 +267,31 @@ export default function MCPUIResourceRenderer({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSizeChangeCase = async (
|
|
||||||
actionEvent: UISizeChange
|
|
||||||
): Promise<UIActionHandlerResult> => {
|
|
||||||
return {
|
|
||||||
status: 'success' as const,
|
|
||||||
message: 'Size change handled',
|
|
||||||
data: actionEvent.payload,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIframeReadyCase = async (
|
|
||||||
actionEvent: UILifecycleIframeReady
|
|
||||||
): Promise<UIActionHandlerResult> => {
|
|
||||||
console.log('[MCP-UI] Iframe ready to receive messages');
|
|
||||||
return {
|
|
||||||
status: 'success' as const,
|
|
||||||
message: 'Iframe is ready to receive messages',
|
|
||||||
data: actionEvent.payload,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestDataCase = async (
|
|
||||||
actionEvent: UIRequestData
|
|
||||||
): Promise<UIActionHandlerResult> => {
|
|
||||||
const { messageId, payload } = actionEvent;
|
|
||||||
const { requestType, params } = payload;
|
|
||||||
console.log('[MCP-UI] Data request received:', { messageId, requestType, params });
|
|
||||||
return {
|
|
||||||
status: 'success' as const,
|
|
||||||
message: `Data request received: ${requestType}`,
|
|
||||||
data: {
|
|
||||||
messageId,
|
|
||||||
requestType,
|
|
||||||
params,
|
|
||||||
response: { status: 'acknowledged' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (actionEvent.type) {
|
switch (actionEvent.type) {
|
||||||
case 'tool':
|
case 'tool':
|
||||||
result = await handleToolCase(actionEvent);
|
result = await handleToolAction(actionEvent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'prompt':
|
case 'prompt':
|
||||||
result = await handlePromptCase(actionEvent);
|
result = await handlePromptAction(actionEvent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'link':
|
case 'link':
|
||||||
result = await handleLinkCase(actionEvent);
|
result = await handleLinkAction(actionEvent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'notify':
|
case 'notify':
|
||||||
result = await handleNotifyCase(actionEvent);
|
result = await handleNotifyAction(actionEvent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'intent':
|
case 'intent':
|
||||||
result = await handleIntentCase(actionEvent);
|
result = await handleIntentAction(actionEvent);
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ui-size-change':
|
|
||||||
result = await handleSizeChangeCase(actionEvent);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ui-lifecycle-iframe-ready':
|
|
||||||
result = await handleIframeReadyCase(actionEvent);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ui-request-data':
|
|
||||||
result = await handleRequestDataCase(actionEvent);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const _exhaustiveCheck: never = actionEvent;
|
const _exhaustiveCheck: never = actionEvent;
|
||||||
console.error('Unhandled action type:', _exhaustiveCheck);
|
console.error('Unhandled MCP-UI action type:', _exhaustiveCheck);
|
||||||
result = {
|
result = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: {
|
error: {
|
||||||
@@ -372,7 +303,7 @@ export default function MCPUIResourceRenderer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MCP-UI] Unexpected error:', error);
|
console.error('Unexpected error handling MCP-UI action:', error);
|
||||||
result = {
|
result = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: {
|
error: {
|
||||||
@@ -383,12 +314,6 @@ export default function MCPUIResourceRenderer({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'error') {
|
|
||||||
console.error('[MCP-UI] Action failed:', result);
|
|
||||||
} else {
|
|
||||||
console.log('[MCP-UI] Action succeeded:', result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,19 +323,20 @@ export default function MCPUIResourceRenderer({
|
|||||||
<UIResourceRenderer
|
<UIResourceRenderer
|
||||||
resource={content.resource}
|
resource={content.resource}
|
||||||
onUIAction={handleUIAction}
|
onUIAction={handleUIAction}
|
||||||
|
supportedContentTypes={['rawHtml', 'externalUrl']} // Goose does not support remoteDom content
|
||||||
htmlProps={{
|
htmlProps={{
|
||||||
autoResizeIframe: {
|
autoResizeIframe: {
|
||||||
height: true,
|
height: true,
|
||||||
width: false, // set to false to allow for responsive design
|
width: false, // set to false to allow for responsive design
|
||||||
},
|
},
|
||||||
sandboxPermissions: 'allow-forms', // enabled for experimentation, is spread into underlying iframe defaults
|
|
||||||
iframeRenderData: {
|
iframeRenderData: {
|
||||||
// iframeRenderData allows us to pass data down to MCP-UIs
|
// iframeRenderData allows us to pass data down to MCP-UIs
|
||||||
// MPC-UIs might find stuff like host and theme for conditional rendering
|
// MCP-UIs might find stuff like host and theme for conditional rendering
|
||||||
// usage of this is experimental, leaving in place for demos
|
// usage of this is experimental, leaving in place for demos
|
||||||
host: 'goose',
|
host: 'goose',
|
||||||
theme: currentThemeValue,
|
theme: currentThemeValue,
|
||||||
},
|
},
|
||||||
|
proxy: proxyUrl, // refer to https://mcpui.dev/guide/client/using-a-proxy
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user