Skip to main content

faucet_cli/serve/
mcp_route.rs

1//! `/mcp` HTTP route for `faucet serve --mcp` (issue #420).
2//!
3//! A Streamable-HTTP MCP transport: one JSON-RPC 2.0 request per POST, the
4//! response returned as the body. The route is mounted inside the
5//! bearer-auth/RBAC `route_layer`, so every call is authenticated and lands in
6//! the audit log like any other control-plane action. Mutating tools
7//! additionally require the caller to hold the `RunWrite` scope — ANDed with
8//! the server's `--mcp-allow-mutations` flag — so a `Viewer` token can never
9//! mutate even on a mutation-enabled server.
10
11use 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/// Per-route MCP flags injected in `build_router`.
19#[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    // Mutating tools require BOTH the server flag and the caller's RunWrite scope.
31    let can_mutate = flags.allow_mutations && actor.role.grants(Permission::RunWrite);
32    // The config-executing tools (`validate_config`, `preview`) build the
33    // connectors the caller describes and resolve `${env:}` / `${file:}` /
34    // `${secret:}` against this process's environment and filesystem. That is
35    // server-side file read plus outbound network reach, so it takes the same
36    // scope as `POST /v1/doctor` — which submits a config to be probed — rather
37    // than the route's baseline read scope. Without this a `viewer` token could
38    // read arbitrary server files via a `csv` source (#456 C4).
39    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    // The server's run-history backend doubles as the pipeline-template registry
52    // (#444), so an agent on `/mcp` sees the same templates the `/v1/templates`
53    // endpoints and `faucet template` do.
54    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    // Best-effort audit: record the MCP call under the caller's principal/role.
61    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        // A notification (no id) gets no body.
66        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        // RBAC gate: a Viewer lacks RunWrite, so the mutating tool stays hidden
110        // even on a mutation-enabled server.
111        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    /// #456 C4: `/mcp`'s baseline scope is `SchemaRead` (Viewer), but `preview`
125    /// builds the source a caller names and returns its records — so a Viewer
126    /// could read any file the server can (`csv` with `path: /etc/passwd`) and
127    /// reach any host it can. Those two tools now need the `Doctor` scope, the
128    /// same one `POST /v1/doctor` requires for submitting a config to be probed.
129    #[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    /// …and calling one anyway is refused rather than executed.
148    #[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    /// An operator holds `Doctor`, so the tools are available to them.
167    #[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        // The call was recorded in the audit log under action "mcp".
216        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}