use crate::serve::rbac::{AuthContext, Permission};
use crate::serve::state::ServerState;
use axum::Extension;
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
#[derive(Clone, Copy)]
pub struct McpRouteFlags {
pub allow_mutations: bool,
}
pub async fn handle(
State(state): State<ServerState>,
Extension(actor): Extension<AuthContext>,
Extension(flags): Extension<McpRouteFlags>,
body: String,
) -> axum::response::Response {
let can_mutate = flags.allow_mutations && actor.role.grants(Permission::RunWrite);
let can_execute_config = actor.role.grants(Permission::Doctor);
let auth = match crate::auth_catalog::build_auth_catalog(None) {
Ok(a) => a,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to build auth catalog: {e}"),
)
.into_response();
}
};
let ctx =
crate::mcp::McpContext::new(auth, can_mutate).with_config_execution(can_execute_config);
#[cfg(feature = "templates")]
let ctx = ctx.with_templates(state.history());
let response = crate::mcp::handle_message(&ctx, &body).await;
crate::serve::audit::write(&state, &actor, "mcp", None, None, "ok").await;
match response {
Some(json) => ([(header::CONTENT_TYPE, "application/json")], json).into_response(),
None => StatusCode::ACCEPTED.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::serve::history::AuditFilter;
use crate::serve::rbac::Role;
use crate::serve::test_support::test_state;
fn actor(role: Role) -> AuthContext {
AuthContext {
principal: "tester".into(),
role,
source_ip: None,
}
}
async fn body_text(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn admin_with_flag_sees_run_pipeline() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Admin)),
Extension(McpRouteFlags {
allow_mutations: true,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
)
.await;
assert!(body_text(resp).await.contains("run_pipeline"));
}
#[tokio::test]
async fn viewer_cannot_see_run_pipeline_even_with_flag() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Viewer)),
Extension(McpRouteFlags {
allow_mutations: true,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
)
.await;
assert!(!body_text(resp).await.contains("run_pipeline"));
}
#[tokio::test]
async fn viewer_cannot_reach_the_config_executing_tools() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Viewer)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
)
.await;
let body = body_text(resp).await;
assert!(!body.contains("validate_config"), "{body}");
assert!(!body.contains("\"preview\""), "{body}");
assert!(body.contains("list_connectors"), "{body}");
}
#[tokio::test]
async fn viewer_calling_preview_is_refused() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Viewer)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"preview","arguments":{"config":"version: 1\npipeline:\n source: { type: csv, config: { path: /etc/passwd } }\n sink: { type: stdout, config: {} }\n"}}}"#.to_string(),
)
.await;
let body = body_text(resp).await;
assert!(body.contains("\"isError\":true"), "{body}");
assert!(body.contains("operator"), "{body}");
assert!(!body.contains("root:"), "no file content may leak: {body}");
}
#[tokio::test]
async fn operator_can_see_the_config_executing_tools() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Operator)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
)
.await;
let body = body_text(resp).await;
assert!(body.contains("validate_config"), "{body}");
assert!(body.contains("preview"), "{body}");
}
#[tokio::test]
async fn flag_off_hides_run_pipeline_for_admin() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Admin)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
)
.await;
assert!(!body_text(resp).await.contains("run_pipeline"));
}
#[tokio::test]
async fn tools_call_is_dispatched_and_audited() {
let state = test_state();
let resp = handle(
State(state.clone()),
Extension(actor(Role::Viewer)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_connectors","arguments":{"kind":"state"}}}"#.to_string(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_text(resp).await;
assert!(body.contains("state_stores"));
let entries = state
.history()
.list_audit(&AuditFilter {
limit: 10,
..Default::default()
})
.await
.unwrap();
assert!(
entries
.iter()
.any(|e| e.action == "mcp" && e.principal == "tester")
);
}
#[tokio::test]
async fn notification_returns_202() {
let state = test_state();
let resp = handle(
State(state),
Extension(actor(Role::Admin)),
Extension(McpRouteFlags {
allow_mutations: false,
}),
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#.to_string(),
)
.await;
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
}