1use crate::serve::rbac::{AuthContext, Permission};
12use crate::serve::state::ServerState;
13use axum::Extension;
14use axum::extract::State;
15use axum::http::{StatusCode, header};
16use axum::response::IntoResponse;
17
18#[derive(Clone, Copy)]
20pub struct McpRouteFlags {
21 pub allow_mutations: bool,
22}
23
24pub async fn handle(
25 State(state): State<ServerState>,
26 Extension(actor): Extension<AuthContext>,
27 Extension(flags): Extension<McpRouteFlags>,
28 body: String,
29) -> axum::response::Response {
30 let can_mutate = flags.allow_mutations && actor.role.grants(Permission::RunWrite);
32 let can_execute_config = actor.role.grants(Permission::Doctor);
40
41 let auth = match crate::auth_catalog::build_auth_catalog(None) {
42 Ok(a) => a,
43 Err(e) => {
44 return (
45 StatusCode::INTERNAL_SERVER_ERROR,
46 format!("failed to build auth catalog: {e}"),
47 )
48 .into_response();
49 }
50 };
51 let ctx =
55 crate::mcp::McpContext::new(auth, can_mutate).with_config_execution(can_execute_config);
56 #[cfg(feature = "templates")]
57 let ctx = ctx.with_templates(state.history());
58 let response = crate::mcp::handle_message(&ctx, &body).await;
59
60 crate::serve::audit::write(&state, &actor, "mcp", None, None, "ok").await;
62
63 match response {
64 Some(json) => ([(header::CONTENT_TYPE, "application/json")], json).into_response(),
65 None => StatusCode::ACCEPTED.into_response(),
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::serve::history::AuditFilter;
74 use crate::serve::rbac::Role;
75 use crate::serve::test_support::test_state;
76
77 fn actor(role: Role) -> AuthContext {
78 AuthContext {
79 principal: "tester".into(),
80 role,
81 source_ip: None,
82 }
83 }
84
85 async fn body_text(resp: axum::response::Response) -> String {
86 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
87 .await
88 .unwrap();
89 String::from_utf8(bytes.to_vec()).unwrap()
90 }
91
92 #[tokio::test]
93 async fn admin_with_flag_sees_run_pipeline() {
94 let state = test_state();
95 let resp = handle(
96 State(state),
97 Extension(actor(Role::Admin)),
98 Extension(McpRouteFlags {
99 allow_mutations: true,
100 }),
101 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
102 )
103 .await;
104 assert!(body_text(resp).await.contains("run_pipeline"));
105 }
106
107 #[tokio::test]
108 async fn viewer_cannot_see_run_pipeline_even_with_flag() {
109 let state = test_state();
112 let resp = handle(
113 State(state),
114 Extension(actor(Role::Viewer)),
115 Extension(McpRouteFlags {
116 allow_mutations: true,
117 }),
118 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
119 )
120 .await;
121 assert!(!body_text(resp).await.contains("run_pipeline"));
122 }
123
124 #[tokio::test]
130 async fn viewer_cannot_reach_the_config_executing_tools() {
131 let state = test_state();
132 let resp = handle(
133 State(state),
134 Extension(actor(Role::Viewer)),
135 Extension(McpRouteFlags {
136 allow_mutations: false,
137 }),
138 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
139 )
140 .await;
141 let body = body_text(resp).await;
142 assert!(!body.contains("validate_config"), "{body}");
143 assert!(!body.contains("\"preview\""), "{body}");
144 assert!(body.contains("list_connectors"), "{body}");
145 }
146
147 #[tokio::test]
149 async fn viewer_calling_preview_is_refused() {
150 let state = test_state();
151 let resp = handle(
152 State(state),
153 Extension(actor(Role::Viewer)),
154 Extension(McpRouteFlags {
155 allow_mutations: false,
156 }),
157 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(),
158 )
159 .await;
160 let body = body_text(resp).await;
161 assert!(body.contains("\"isError\":true"), "{body}");
162 assert!(body.contains("operator"), "{body}");
163 assert!(!body.contains("root:"), "no file content may leak: {body}");
164 }
165
166 #[tokio::test]
168 async fn operator_can_see_the_config_executing_tools() {
169 let state = test_state();
170 let resp = handle(
171 State(state),
172 Extension(actor(Role::Operator)),
173 Extension(McpRouteFlags {
174 allow_mutations: false,
175 }),
176 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
177 )
178 .await;
179 let body = body_text(resp).await;
180 assert!(body.contains("validate_config"), "{body}");
181 assert!(body.contains("preview"), "{body}");
182 }
183
184 #[tokio::test]
185 async fn flag_off_hides_run_pipeline_for_admin() {
186 let state = test_state();
187 let resp = handle(
188 State(state),
189 Extension(actor(Role::Admin)),
190 Extension(McpRouteFlags {
191 allow_mutations: false,
192 }),
193 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string(),
194 )
195 .await;
196 assert!(!body_text(resp).await.contains("run_pipeline"));
197 }
198
199 #[tokio::test]
200 async fn tools_call_is_dispatched_and_audited() {
201 let state = test_state();
202 let resp = handle(
203 State(state.clone()),
204 Extension(actor(Role::Viewer)),
205 Extension(McpRouteFlags {
206 allow_mutations: false,
207 }),
208 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_connectors","arguments":{"kind":"state"}}}"#.to_string(),
209 )
210 .await;
211 assert_eq!(resp.status(), StatusCode::OK);
212 let body = body_text(resp).await;
213 assert!(body.contains("state_stores"));
214
215 let entries = state
217 .history()
218 .list_audit(&AuditFilter {
219 limit: 10,
220 ..Default::default()
221 })
222 .await
223 .unwrap();
224 assert!(
225 entries
226 .iter()
227 .any(|e| e.action == "mcp" && e.principal == "tester")
228 );
229 }
230
231 #[tokio::test]
232 async fn notification_returns_202() {
233 let state = test_state();
234 let resp = handle(
235 State(state),
236 Extension(actor(Role::Admin)),
237 Extension(McpRouteFlags {
238 allow_mutations: false,
239 }),
240 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#.to_string(),
241 )
242 .await;
243 assert_eq!(resp.status(), StatusCode::ACCEPTED);
244 }
245}