Skip to main content

faucet_cli/mcp/
mod.rs

1//! MCP (Model Context Protocol) server surface for faucet (issue #420).
2//!
3//! A transport-agnostic JSON-RPC 2.0 dispatcher ([`handle_message`]) plus the
4//! tool implementations ([`tools`]). Two front-ends drive it:
5//!
6//! - **stdio** — the `faucet mcp` subcommand ([`crate::commands::mcp`]), for
7//!   local agents (Claude Desktop / Code).
8//! - **Streamable HTTP** — the `/mcp` route mounted by `faucet serve --mcp`,
9//!   which inherits serve's bearer-auth + RBAC + audit.
10//!
11//! The dispatcher re-exposes existing faucet capabilities (list / schema /
12//! scaffold / validate / preview, and a gated `run_pipeline`) in the shape an
13//! LLM agent speaks; it re-implements no pipeline logic. Read-only tools are
14//! always available; the mutating `run_pipeline` tool appears only when the
15//! context is constructed with `allow_mutations = true` (the `--allow-mutations`
16//! flag, ANDed with the caller's RBAC scope on the HTTP transport).
17
18pub mod protocol;
19pub mod tools;
20
21use crate::auth_catalog::AuthCatalog;
22use protocol::*;
23use serde_json::{Value, json};
24
25/// Everything a tool handler needs, independent of transport.
26pub struct McpContext {
27    /// Shared auth providers (for `preview`/`run_pipeline` connector builds).
28    pub auth: AuthCatalog,
29    /// Whether mutating tools (`run_pipeline`, `register_template`,
30    /// `run_template`) are exposed and callable.
31    pub allow_mutations: bool,
32    /// Whether tools that **act on a caller-supplied config** — `validate_config`
33    /// and `preview` — are exposed and callable.
34    ///
35    /// These are read-only in the sense that they write nothing, but they are not
36    /// harmless: `preview` constructs the connector the caller describes and
37    /// returns its records, and both resolve `${env:}` / `${file:}` /
38    /// `${secret:}` against the *server's* environment and filesystem. On the
39    /// HTTP transport that is server-side file read and outbound network reach,
40    /// so it requires the same scope as `POST /v1/doctor` (operator+) rather than
41    /// the schema-read scope the route's baseline uses (#456 C4). The stdio
42    /// transport runs as the local user and sets this `true`.
43    pub allow_config_execution: bool,
44    /// The pipeline template registry (#444), when one is wired: `faucet serve
45    /// --mcp` passes its own `--history` backend; `faucet mcp` needs
46    /// `--template-store`. Absent = the template tools are not advertised at all,
47    /// so an agent never sees a tool it cannot use.
48    #[cfg(feature = "templates")]
49    pub templates: Option<crate::templates::TemplateStore>,
50}
51
52impl McpContext {
53    /// A context for a **local** caller (the `faucet mcp` stdio transport), which
54    /// already runs with the user's own privileges: config-executing tools are
55    /// enabled, mutations follow `allow_mutations`.
56    pub fn new(auth: AuthCatalog, allow_mutations: bool) -> Self {
57        Self {
58            auth,
59            allow_mutations,
60            allow_config_execution: true,
61            #[cfg(feature = "templates")]
62            templates: None,
63        }
64    }
65
66    /// Set whether `validate_config` / `preview` are available. The HTTP
67    /// transport passes the caller's RBAC decision here.
68    pub fn with_config_execution(mut self, allow: bool) -> Self {
69        self.allow_config_execution = allow;
70        self
71    }
72
73    /// Attach a template registry, enabling the template tools.
74    #[cfg(feature = "templates")]
75    pub fn with_templates(mut self, store: crate::templates::TemplateStore) -> Self {
76        self.templates = Some(store);
77        self
78    }
79}
80
81/// Install a tracing subscriber that writes to **stderr** — stdout is reserved
82/// for the JSON-RPC message stream in `faucet mcp` (stdio) mode.
83pub fn install_stderr_tracing(level: &str) {
84    use tracing_subscriber::EnvFilter;
85    let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
86    let _ = tracing_subscriber::fmt()
87        .with_env_filter(filter)
88        .with_writer(std::io::stderr)
89        .try_init();
90}
91
92/// Server identity reported in `initialize`.
93fn server_info() -> Value {
94    json!({ "name": "faucet", "version": env!("CARGO_PKG_VERSION") })
95}
96
97/// Handle one JSON-RPC message. Returns `Some(response_json)` for a request,
98/// or `None` for a notification (which gets no response). A malformed message
99/// yields a JSON-RPC parse/invalid-request error envelope.
100pub async fn handle_message(ctx: &McpContext, raw: &str) -> Option<String> {
101    let req: JsonRpcRequest = match serde_json::from_str(raw) {
102        Ok(r) => r,
103        Err(e) => {
104            return Some(render(error_no_id(
105                PARSE_ERROR,
106                format!("parse error: {e}"),
107            )));
108        }
109    };
110
111    // Notifications (no id) get no response, whatever the method.
112    if req.is_notification() {
113        return None;
114    }
115    let id = req.id.clone().unwrap_or(Value::Null);
116
117    let resp = match req.method.as_str() {
118        "initialize" => success(
119            id,
120            json!({
121                "protocolVersion": PROTOCOL_VERSION,
122                "capabilities": { "tools": {}, "resources": {} },
123                "serverInfo": server_info(),
124            }),
125        ),
126        "ping" => success(id, json!({})),
127        "tools/list" => {
128            let defs = tools::tool_defs(ctx);
129            success(id, json!({ "tools": defs }))
130        }
131        "tools/call" => match req.params.get("name").and_then(Value::as_str) {
132            Some(name) => {
133                let empty = json!({});
134                let args = req.params.get("arguments").unwrap_or(&empty);
135                let result = tools::call_tool(ctx, name, args).await;
136                success(id, result)
137            }
138            None => error(id, INVALID_PARAMS, "tools/call requires a 'name' parameter"),
139        },
140        "resources/list" => success(id, json!({ "resources": [] })),
141        "resources/read" => error(
142            id,
143            INVALID_PARAMS,
144            "no readable resources are exposed in this version",
145        ),
146        other => error(id, METHOD_NOT_FOUND, format!("unknown method '{other}'")),
147    };
148    Some(render(resp))
149}
150
151/// Drive an MCP session over any byte streams: read newline-delimited JSON-RPC
152/// from `reader`, write each response (one JSON object per line) to `writer`.
153/// Blank lines are skipped; notifications produce no output. Returns when the
154/// reader hits EOF. The `faucet mcp` stdio command wraps stdin/stdout with
155/// this; tests drive it with in-memory buffers.
156pub async fn serve_stdio<R, W>(ctx: &McpContext, reader: R, writer: &mut W) -> std::io::Result<()>
157where
158    R: tokio::io::AsyncBufRead + Unpin,
159    W: tokio::io::AsyncWrite + Unpin,
160{
161    use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
162    let mut lines = reader.lines();
163    while let Some(line) = lines.next_line().await? {
164        let trimmed = line.trim();
165        if trimmed.is_empty() {
166            continue;
167        }
168        if let Some(response) = handle_message(ctx, trimmed).await {
169            writer.write_all(response.as_bytes()).await?;
170            writer.write_all(b"\n").await?;
171            writer.flush().await?;
172        }
173    }
174    Ok(())
175}
176
177fn render(v: Value) -> String {
178    serde_json::to_string(&v).unwrap_or_else(|_| {
179        r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"failed to serialize response"}}"#.to_string()
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn ctx() -> McpContext {
188        McpContext::new(
189            crate::auth_catalog::build_auth_catalog(None).unwrap(),
190            false,
191        )
192    }
193
194    async fn call(raw: &str) -> Value {
195        let s = handle_message(&ctx(), raw).await.expect("response");
196        serde_json::from_str(&s).unwrap()
197    }
198
199    #[tokio::test]
200    async fn initialize_reports_protocol_and_server() {
201        let v = call(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#).await;
202        assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
203        assert_eq!(v["result"]["serverInfo"]["name"], "faucet");
204        assert!(v["result"]["capabilities"]["tools"].is_object());
205    }
206
207    #[tokio::test]
208    async fn ping_ok() {
209        let v = call(r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#).await;
210        assert!(v["result"].is_object());
211    }
212
213    #[tokio::test]
214    async fn notification_gets_no_response() {
215        let out = handle_message(
216            &ctx(),
217            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
218        )
219        .await;
220        assert!(out.is_none());
221    }
222
223    #[tokio::test]
224    async fn tools_list_has_readonly_and_hides_mutations() {
225        let v = call(r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#).await;
226        let names: Vec<String> = v["result"]["tools"]
227            .as_array()
228            .unwrap()
229            .iter()
230            .map(|t| t["name"].as_str().unwrap().to_string())
231            .collect();
232        assert!(names.contains(&"list_connectors".to_string()));
233        assert!(names.contains(&"validate_config".to_string()));
234        assert!(!names.contains(&"run_pipeline".to_string()));
235    }
236
237    /// #456 C4: a caller without the config-execution scope must neither see nor
238    /// be able to invoke the tools that build connectors from a config they
239    /// supply — that is server-side file read and outbound network reach.
240    #[tokio::test]
241    async fn config_executing_tools_are_hidden_and_refused_without_the_scope() {
242        let ctx = McpContext::new(
243            crate::auth_catalog::build_auth_catalog(None).unwrap(),
244            false,
245        )
246        .with_config_execution(false);
247
248        let listed = handle_message(&ctx, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
249            .await
250            .unwrap();
251        assert!(
252            !listed.contains("validate_config") && !listed.contains("\"preview\""),
253            "must not be advertised: {listed}"
254        );
255        // Still present: pure introspection needs no elevated scope.
256        assert!(listed.contains("list_connectors"), "{listed}");
257
258        // Naming an unadvertised tool must be refused, not executed.
259        for tool in ["validate_config", "preview"] {
260            let out = tools::call_tool(
261                &ctx,
262                tool,
263                &json!({ "config": "version: 1\npipeline: {}\n" }),
264            )
265            .await;
266            assert_eq!(out["isError"], true, "{tool} must be refused");
267            let text = out["content"][0]["text"].as_str().unwrap();
268            assert!(text.contains("operator"), "{tool}: {text}");
269        }
270    }
271
272    #[tokio::test]
273    async fn tools_list_shows_mutations_when_allowed() {
274        let ctx = McpContext::new(crate::auth_catalog::build_auth_catalog(None).unwrap(), true);
275        let s = handle_message(&ctx, r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#)
276            .await
277            .unwrap();
278        assert!(s.contains("run_pipeline"));
279    }
280
281    #[tokio::test]
282    async fn tools_call_dispatches() {
283        let v = call(
284            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_connectors","arguments":{"kind":"state"}}}"#,
285        )
286        .await;
287        assert_eq!(v["result"]["isError"], false);
288        assert!(
289            v["result"]["content"][0]["text"]
290                .as_str()
291                .unwrap()
292                .contains("state_stores")
293        );
294    }
295
296    #[tokio::test]
297    async fn tools_call_without_name_is_invalid_params() {
298        let v = call(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{}}"#).await;
299        assert_eq!(v["error"]["code"], INVALID_PARAMS);
300    }
301
302    #[tokio::test]
303    async fn unknown_method_is_method_not_found() {
304        let v = call(r#"{"jsonrpc":"2.0","id":6,"method":"frobnicate"}"#).await;
305        assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
306    }
307
308    #[tokio::test]
309    async fn malformed_json_is_parse_error() {
310        let s = handle_message(&ctx(), "not json").await.unwrap();
311        let v: Value = serde_json::from_str(&s).unwrap();
312        assert_eq!(v["error"]["code"], PARSE_ERROR);
313    }
314
315    #[tokio::test]
316    async fn resources_list_is_empty() {
317        let v = call(r#"{"jsonrpc":"2.0","id":7,"method":"resources/list"}"#).await;
318        assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 0);
319    }
320
321    #[tokio::test]
322    async fn resources_read_is_invalid_params() {
323        let v = call(r#"{"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri":"x"}}"#)
324            .await;
325        assert_eq!(v["error"]["code"], INVALID_PARAMS);
326    }
327
328    #[test]
329    fn install_stderr_tracing_is_idempotent() {
330        // Just exercises the installer (try_init, so a second global subscriber
331        // is a no-op rather than a panic).
332        install_stderr_tracing("info");
333        install_stderr_tracing("debug");
334    }
335
336    #[tokio::test]
337    async fn serve_stdio_processes_lines_and_skips_blanks_and_notifications() {
338        use std::io::Cursor;
339        // A request, a blank line (skipped), a notification (no output), then a
340        // second request. Expect exactly two response lines.
341        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\
342                     \n\
343                     {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n\
344                     {\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n";
345        let mut output: Vec<u8> = Vec::new();
346        let reader = tokio::io::BufReader::new(Cursor::new(input.as_bytes().to_vec()));
347        serve_stdio(&ctx(), reader, &mut output).await.unwrap();
348        let text = String::from_utf8(output).unwrap();
349        let lines: Vec<&str> = text.lines().collect();
350        assert_eq!(
351            lines.len(),
352            2,
353            "one response per request, none for blank/notification"
354        );
355        let first: Value = serde_json::from_str(lines[0]).unwrap();
356        assert_eq!(first["id"], 1);
357        let second: Value = serde_json::from_str(lines[1]).unwrap();
358        assert!(second["result"]["tools"].is_array());
359    }
360}