feat(mcp): add streamable http server and fine-grained authorization
This commit is contained in:
47 files changed
+4960
-509
No files matched your search
@@ -22,6 +22,7 @@ use dbx_core::connection::AppState;
|
||||
use dbx_core::sql_dialect::dialect_loader::{register_core_dialects, DialectPluginLoader, DialectRegistry};
|
||||
use dbx_core::sql_dialect::hot_reload::DialectHotReload;
|
||||
use dbx_core::storage::Storage;
|
||||
use dbx_mcp::{streamable_http_router, DbxBackend, HttpAuth, LocalBackend};
|
||||
use state::WebState;
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||
@@ -147,6 +148,56 @@ fn mount_public_base_path(mut app: Router, public_base_path: &str, static_dir: O
|
||||
app
|
||||
}
|
||||
|
||||
/// Builds the native Web MCP endpoint. It is intentionally opt-in: exposing a
|
||||
/// token-bearing MCP server on a Web listener must never happen merely because
|
||||
/// DBX Web itself was started.
|
||||
fn web_mcp_router(web_state: &Arc<WebState>) -> Result<Option<Router>, String> {
|
||||
let token = web_mcp_token()?;
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let allowed_hosts = comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS");
|
||||
if allowed_hosts.is_empty() {
|
||||
return Err("DBX_WEB_MCP_ALLOWED_HOSTS is required when DBX Web MCP is enabled".into());
|
||||
}
|
||||
|
||||
let allowed_origins = comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS");
|
||||
let auth = HttpAuth::new(token, allowed_origins, false)?;
|
||||
let backend: Arc<dyn DbxBackend> =
|
||||
Arc::new(LocalBackend::from_app_state(web_state.app.clone(), web_state.data_dir.clone()));
|
||||
|
||||
Ok(Some(streamable_http_router(backend, "/mcp", auth, allowed_hosts, true)))
|
||||
}
|
||||
|
||||
fn web_mcp_token() -> Result<Option<String>, String> {
|
||||
let inline_token = std::env::var("DBX_WEB_MCP_TOKEN").ok();
|
||||
let token_file = std::env::var("DBX_WEB_MCP_TOKEN_FILE").ok();
|
||||
match (inline_token, token_file) {
|
||||
(Some(_), Some(_)) => Err("set only one of DBX_WEB_MCP_TOKEN or DBX_WEB_MCP_TOKEN_FILE".into()),
|
||||
(Some(token), None) if !token.trim().is_empty() => Ok(Some(token)),
|
||||
(Some(_), None) => Err("DBX_WEB_MCP_TOKEN must not be empty".into()),
|
||||
(None, Some(path)) => std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read DBX_WEB_MCP_TOKEN_FILE: {error}"))
|
||||
.map(|token| token.trim_end_matches(['\r', '\n']).to_owned())
|
||||
.and_then(|token| {
|
||||
(!token.is_empty()).then_some(token).ok_or_else(|| "DBX_WEB_MCP_TOKEN_FILE is empty".into())
|
||||
})
|
||||
.map(Some),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated_env(name: &str) -> Vec<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
||||
router
|
||||
@@ -994,6 +1045,7 @@ async fn main() {
|
||||
"/app-settings/mcp-policy",
|
||||
get(routes::app_settings::load_mcp_global_policy).put(routes::app_settings::save_mcp_global_policy),
|
||||
)
|
||||
.route("/app-settings/mcp-http-status", get(routes::app_settings::load_web_mcp_http_status))
|
||||
.route(
|
||||
"/app-settings/max-agent-turns",
|
||||
get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns),
|
||||
@@ -1045,6 +1097,11 @@ async fn main() {
|
||||
.layer(CompressionLayer::new().compress_when(web_compression_predicate()))
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
if let Some(mcp_router) = web_mcp_router(&web_state).expect("Invalid DBX Web MCP configuration") {
|
||||
app = app.merge(mcp_router);
|
||||
tracing::info!("DBX Web MCP is enabled at /mcp");
|
||||
}
|
||||
|
||||
let static_dir = std::env::var_os("DBX_STATIC_DIR").map(std::path::PathBuf::from);
|
||||
app = mount_public_base_path(app, &public_base_path, static_dir.as_deref());
|
||||
|
||||
|
||||
@@ -65,6 +65,43 @@ pub async fn save_mcp_global_policy(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebMcpHttpStatus {
|
||||
pub enabled: bool,
|
||||
pub endpoint_path: String,
|
||||
pub token_source: Option<&'static str>,
|
||||
pub allowed_hosts: Vec<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn load_web_mcp_http_status(State(state): State<Arc<WebState>>) -> Json<WebMcpHttpStatus> {
|
||||
let token_source = match (std::env::var_os("DBX_WEB_MCP_TOKEN"), std::env::var_os("DBX_WEB_MCP_TOKEN_FILE")) {
|
||||
(Some(_), None) => Some("environment"),
|
||||
(None, Some(_)) => Some("file"),
|
||||
_ => None,
|
||||
};
|
||||
let endpoint_path =
|
||||
if state.public_base_path == "/" { "/mcp".to_string() } else { format!("{}/mcp", state.public_base_path) };
|
||||
Json(WebMcpHttpStatus {
|
||||
enabled: token_source.is_some(),
|
||||
endpoint_path,
|
||||
token_source,
|
||||
allowed_hosts: comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS"),
|
||||
allowed_origins: comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS"),
|
||||
})
|
||||
}
|
||||
|
||||
fn comma_separated_env(name: &str) -> Vec<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveMaxAgentTurnsRequest {
|
||||
|
||||
@@ -1227,7 +1227,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![existing.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1300,7 +1300,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![removed.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1331,7 +1331,7 @@ mod tests {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -986,7 +986,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
};
|
||||
state.app.storage.save_mcp_global_policy(&writable_policy).await.unwrap();
|
||||
|
||||
@@ -1033,7 +1033,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["different-connection".to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1058,7 +1058,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1088,7 +1088,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![connection.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1586,7 +1586,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec![connection_id.to_string()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user