Skip to main content

faucet_cli/mcp/
tools.rs

1//! MCP tool definitions + in-process dispatch (issue #420).
2//!
3//! Every tool is a thin shape-adapter over an existing faucet capability —
4//! `registry` (list / schema), `init_template` (scaffold), `expand` /
5//! `topology` (validate), `preview`, and `run_from_yaml_str` (the one gated
6//! mutating tool). No pipeline logic is re-implemented here.
7
8use super::McpContext;
9use crate::config::PipelineConfig;
10use crate::mcp::protocol::{ToolDef, tool_error, tool_text};
11use serde_json::{Value, json};
12use std::path::Path;
13
14/// Hard cap on `preview` rows regardless of the requested limit — an MCP
15/// client must never trigger a full extract of a large source.
16const PREVIEW_MAX: usize = 100;
17
18/// Build the advertised tool list for `tools/list`, honoring the mutation gate.
19pub fn tool_defs(ctx: &McpContext) -> Vec<ToolDef> {
20    let mut defs = vec![
21        ToolDef {
22            name: "list_connectors",
23            description: "List all compiled-in sources, sinks, transforms, and state stores, each with a one-line description and (for connectors) a conformance tier.",
24            input_schema: json!({
25                "type": "object",
26                "properties": {
27                    "kind": { "type": "string", "enum": ["source", "sink", "transform", "state", "all"], "description": "Filter to one category (default: all)." }
28                }
29            }),
30        },
31        ToolDef {
32            name: "get_connector_schema",
33            description: "Return the JSON Schema for a connector or transform's config block.",
34            input_schema: json!({
35                "type": "object",
36                "properties": {
37                    "kind": { "type": "string", "enum": ["source", "sink", "transform"] },
38                    "name": { "type": "string", "description": "Connector/transform name, e.g. 'rest' or 'keys_case'." }
39                },
40                "required": ["kind", "name"]
41            }),
42        },
43        ToolDef {
44            name: "scaffold_config",
45            description: "Generate a commented YAML pipeline skeleton for a source→sink pair (read-only: returns text, writes nothing).",
46            input_schema: json!({
47                "type": "object",
48                "properties": {
49                    "source": { "type": "string", "description": "Source connector kind." },
50                    "sink": { "type": "string", "description": "Sink connector kind." },
51                    "name": { "type": "string", "description": "Optional pipeline name." }
52                },
53                "required": ["source", "sink"]
54            }),
55        },
56        ToolDef {
57            name: "validate_config",
58            description: "Fully validate a pipeline YAML/JSON config (structure, templates, matrix/topology graph). Returns a per-node report or the validation error.",
59            input_schema: json!({
60                "type": "object",
61                "properties": {
62                    "config": { "type": "string", "description": "The pipeline config document (YAML or JSON)." }
63                },
64                "required": ["config"]
65            }),
66        },
67        ToolDef {
68            name: "preview",
69            description: "Fetch a bounded sample of records from a config's first source (source side only; downstream sinks are not run). Capped at 100 rows.",
70            input_schema: json!({
71                "type": "object",
72                "properties": {
73                    "config": { "type": "string", "description": "The pipeline config document (YAML or JSON)." },
74                    "limit": { "type": "integer", "description": "Max rows to return (1–100, default 10)." }
75                },
76                "required": ["config"]
77            }),
78        },
79    ];
80    if ctx.allow_mutations {
81        defs.push(ToolDef {
82            name: "run_pipeline",
83            description: "Run a pipeline from an inline config. MUTATING — gated behind --allow-mutations. Pass dry_run:true to validate+preview only.",
84            input_schema: json!({
85                "type": "object",
86                "properties": {
87                    "config": { "type": "string" },
88                    "dry_run": { "type": "boolean", "description": "If true, validate + preview only; do not write to any sink." }
89                },
90                "required": ["config"]
91            }),
92        });
93    }
94    defs
95}
96
97/// Dispatch a `tools/call`. Returns the MCP `tools/call` result envelope
98/// (`content` + `isError`). A tool-level failure is `tool_error(..)`, not a
99/// JSON-RPC protocol error.
100pub async fn call_tool(ctx: &McpContext, name: &str, args: &Value) -> Value {
101    let result: Result<String, String> = match name {
102        "list_connectors" => list_connectors(args),
103        "get_connector_schema" => get_connector_schema(args),
104        "scaffold_config" => scaffold_config(args),
105        "validate_config" => validate_config(ctx, args).await,
106        "preview" => preview(ctx, args).await,
107        "run_pipeline" => {
108            if !ctx.allow_mutations {
109                Err("run_pipeline is disabled; start the MCP server with --allow-mutations to enable mutating tools".to_string())
110            } else {
111                run_pipeline(ctx, args).await
112            }
113        }
114        other => Err(format!("unknown tool '{other}'")),
115    };
116    match result {
117        Ok(text) => tool_text(text),
118        // Redact any resolved secret material that reached an error string.
119        Err(msg) => tool_error(crate::secrets::registry::redact(&msg)),
120    }
121}
122
123fn str_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
124    args.get(key)
125        .and_then(Value::as_str)
126        .ok_or_else(|| format!("missing required string argument '{key}'"))
127}
128
129fn tier_of(kind: &str, is_source: bool) -> &'static str {
130    crate::conformance::tier_for(kind, is_source).as_str()
131}
132
133fn list_connectors(args: &Value) -> Result<String, String> {
134    let filter = args.get("kind").and_then(Value::as_str).unwrap_or("all");
135    let want = |c: &str| filter == "all" || filter == c;
136
137    let mut out = json!({});
138    let obj = out.as_object_mut().unwrap();
139    if want("source") {
140        let sources: Vec<Value> = crate::registry::source_descriptions()
141            .into_iter()
142            .map(|(name, desc)| json!({ "name": name, "description": desc, "tier": tier_of(name, true) }))
143            .collect();
144        obj.insert("sources".into(), json!(sources));
145    }
146    if want("sink") {
147        let sinks: Vec<Value> = crate::registry::sink_descriptions()
148            .into_iter()
149            .map(|(name, desc)| json!({ "name": name, "description": desc, "tier": tier_of(name, false) }))
150            .collect();
151        obj.insert("sinks".into(), json!(sinks));
152    }
153    if want("transform") {
154        let transforms: Vec<Value> = crate::transforms::transform_descriptions()
155            .into_iter()
156            .map(|(name, desc)| json!({ "name": name, "description": desc }))
157            .collect();
158        obj.insert("transforms".into(), json!(transforms));
159    }
160    if want("state") {
161        obj.insert(
162            "state_stores".into(),
163            json!(crate::state::available_state_kinds()),
164        );
165    }
166    Ok(pretty(&out))
167}
168
169fn get_connector_schema(args: &Value) -> Result<String, String> {
170    let kind = str_arg(args, "kind")?;
171    let name = str_arg(args, "name")?;
172    let schema = match kind {
173        "source" => crate::registry::source_schema(name),
174        "sink" => crate::registry::sink_schema(name),
175        "transform" => crate::transforms::transform_schema(name),
176        other => return Err(format!("kind must be source|sink|transform, got '{other}'")),
177    }
178    .map_err(|e| e.to_string())?;
179    Ok(pretty(&schema))
180}
181
182fn scaffold_config(args: &Value) -> Result<String, String> {
183    let source = str_arg(args, "source")?;
184    let sink = str_arg(args, "sink")?;
185    let name = args
186        .get("name")
187        .and_then(Value::as_str)
188        .unwrap_or("pipeline");
189
190    let src_schema = crate::registry::source_schema(source).map_err(|e| e.to_string())?;
191    let sink_schema = crate::registry::sink_schema(sink).map_err(|e| e.to_string())?;
192    let src_yaml = crate::init_template::schema_to_yaml_template(&src_schema, 6);
193    let sink_yaml = crate::init_template::schema_to_yaml_template(&sink_schema, 6);
194
195    Ok(format!(
196        "version: 1\nname: {name}\npipeline:\n  source:\n    type: {source}\n    config:\n{src_yaml}\n  sink:\n    type: {sink}\n    config:\n{sink_yaml}"
197    ))
198}
199
200/// Parse an inline config document. Tries YAML then JSON via `from_text`.
201fn parse_config(text: &str) -> Result<PipelineConfig, String> {
202    // `from_text` picks the parser from the path extension; give it a `.yaml`
203    // path (YAML is a JSON superset, so a JSON document also parses).
204    PipelineConfig::from_text(text, Path::new("mcp-inline.yaml")).map_err(|e| e.to_string())
205}
206
207async fn validate_config(ctx: &McpContext, args: &Value) -> Result<String, String> {
208    let text = str_arg(args, "config")?;
209    let cfg = parse_config(text)?;
210
211    if crate::topology::is_topology(&cfg) {
212        let topo = crate::topology::build_topology(&cfg, &ctx.auth)
213            .await
214            .map_err(|e| e.to_string())?;
215        return Ok(pretty(&json!({
216            "valid": true,
217            "mode": "topology",
218            "nodes": topo.nodes().iter().map(|n| json!({"id": n.id, "kind": n.kind.kind_str()})).collect::<Vec<_>>(),
219            "edges": topo.edges().len(),
220        })));
221    }
222
223    let nodes = crate::expand::expand(&cfg).map_err(|e| e.to_string())?;
224    let rows: Vec<Value> = nodes
225        .iter()
226        .map(|n| {
227            json!({
228                "id": n.id,
229                "source": n.source.kind,
230                "sink": n.sink.kind,
231                "transforms": n.transforms.len(),
232            })
233        })
234        .collect();
235    Ok(pretty(&json!({
236        "valid": true,
237        "mode": "matrix",
238        "name": cfg.name,
239        "rows": rows,
240    })))
241}
242
243async fn preview(ctx: &McpContext, args: &Value) -> Result<String, String> {
244    use faucet_core::stage::{apply_stages, compile_stage};
245
246    let text = str_arg(args, "config")?;
247    let limit = args
248        .get("limit")
249        .and_then(Value::as_u64)
250        .map(|n| (n as usize).clamp(1, PREVIEW_MAX))
251        .unwrap_or(10);
252
253    let cfg = parse_config(text)?;
254    if crate::topology::is_topology(&cfg) {
255        return crate::topology::preview_to_string(&cfg, &ctx.auth, limit)
256            .await
257            .map_err(|e| e.to_string());
258    }
259
260    let nodes = crate::expand::expand(&cfg).map_err(|e| e.to_string())?;
261    let first_root = nodes
262        .iter()
263        .find(|n| matches!(n.role, crate::expand::NodeRole::Root))
264        .ok_or_else(|| "no root row to preview".to_string())?;
265
266    let source = crate::registry::build_source(
267        &first_root.source.kind,
268        first_root.source.config.clone(),
269        &ctx.auth,
270        None,
271    )
272    .await
273    .map_err(|e| e.to_string())?;
274    let stages =
275        crate::transforms::compile_transforms(&first_root.transforms).map_err(|e| e.to_string())?;
276    let records = source.fetch_all().await.map_err(|e| e.to_string())?;
277    let records: Vec<Value> = if stages.is_empty() {
278        records
279    } else {
280        let compiled = stages
281            .iter()
282            .map(compile_stage)
283            .collect::<Result<Vec<_>, _>>()
284            .map_err(|e| e.to_string())?;
285        let mut out = Vec::with_capacity(records.len());
286        for r in records {
287            out.extend(apply_stages(r, &compiled).map_err(|e| e.to_string())?);
288        }
289        out
290    };
291    let limited: Vec<Value> = records.into_iter().take(limit).collect();
292    Ok(pretty(
293        &json!({ "row": first_root.id, "count": limited.len(), "records": limited }),
294    ))
295}
296
297async fn run_pipeline(ctx: &McpContext, args: &Value) -> Result<String, String> {
298    let text = str_arg(args, "config")?;
299    let dry_run = args
300        .get("dry_run")
301        .and_then(Value::as_bool)
302        .unwrap_or(false);
303
304    if dry_run {
305        let mut report = validate_config(ctx, args).await?;
306        report.push_str("\n\n-- preview --\n");
307        report.push_str(
308            &preview(ctx, args)
309                .await
310                .unwrap_or_else(|e| format!("preview skipped: {e}")),
311        );
312        return Ok(report);
313    }
314
315    let summary = crate::run_from_yaml_str(text)
316        .await
317        .map_err(|e| e.to_string())?;
318    let failed = summary.failure_count();
319    let total: usize = summary.invocations.iter().map(|i| i.records_written).sum();
320    let doc = json!({
321        "invocations": summary.invocations.len(),
322        "ok": summary.invocations.len() - failed,
323        "failed": failed,
324        "records_written": total,
325    });
326    if failed > 0 {
327        return Err(format!(
328            "pipeline had {failed} failed invocation(s): {}",
329            pretty(&doc)
330        ));
331    }
332    Ok(pretty(&doc))
333}
334
335fn pretty(v: &Value) -> String {
336    serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::mcp::McpContext;
343
344    fn ctx(allow: bool) -> McpContext {
345        McpContext::new(
346            crate::auth_catalog::build_auth_catalog(None).unwrap(),
347            allow,
348        )
349    }
350
351    #[test]
352    fn tool_defs_gate_mutations() {
353        let ro = tool_defs(&ctx(false));
354        assert!(ro.iter().all(|t| t.name != "run_pipeline"));
355        let rw = tool_defs(&ctx(true));
356        assert!(rw.iter().any(|t| t.name == "run_pipeline"));
357    }
358
359    #[tokio::test]
360    async fn list_connectors_includes_sources_and_tier() {
361        let out = call_tool(&ctx(false), "list_connectors", &json!({})).await;
362        assert_eq!(out["isError"], false);
363        let text = out["content"][0]["text"].as_str().unwrap();
364        assert!(text.contains("\"sources\""));
365        assert!(text.contains("\"tier\""));
366    }
367
368    #[tokio::test]
369    async fn list_connectors_filter_kind() {
370        let out = call_tool(
371            &ctx(false),
372            "list_connectors",
373            &json!({"kind": "transform"}),
374        )
375        .await;
376        let text = out["content"][0]["text"].as_str().unwrap();
377        assert!(text.contains("\"transforms\""));
378        assert!(!text.contains("\"sources\""));
379    }
380
381    #[tokio::test]
382    async fn get_connector_schema_unknown_is_tool_error() {
383        let out = call_tool(
384            &ctx(false),
385            "get_connector_schema",
386            &json!({"kind":"source","name":"nope"}),
387        )
388        .await;
389        assert_eq!(out["isError"], true);
390    }
391
392    #[tokio::test]
393    async fn unknown_tool_errors() {
394        let out = call_tool(&ctx(false), "does_not_exist", &json!({})).await;
395        assert_eq!(out["isError"], true);
396    }
397
398    #[tokio::test]
399    async fn run_pipeline_blocked_without_mutations() {
400        let out = call_tool(&ctx(false), "run_pipeline", &json!({"config":"version: 1"})).await;
401        assert_eq!(out["isError"], true);
402        assert!(
403            out["content"][0]["text"]
404                .as_str()
405                .unwrap()
406                .contains("--allow-mutations")
407        );
408    }
409
410    // ── handler coverage: scaffold / validate / preview / run ────────────────
411
412    fn csv_config(dir: &std::path::Path) -> String {
413        let csv = dir.join("in.csv");
414        std::fs::write(&csv, "id,name\n1,alice\n2,bob\n").unwrap();
415        let out = dir.join("out.jsonl");
416        format!(
417            "version: 1\nname: t\npipeline:\n  source:\n    type: csv\n    config:\n      path: {}\n  sink:\n    type: jsonl\n    config:\n      path: {}\n",
418            csv.display(),
419            out.display()
420        )
421    }
422
423    fn topology_config(dir: &std::path::Path) -> String {
424        let csv = dir.join("in.csv");
425        std::fs::write(&csv, "id,name\n1,alice\n").unwrap();
426        let out = dir.join("out.jsonl");
427        format!(
428            "version: 1\nname: t\npipeline:\n  sources:\n    s: {{ type: csv, config: {{ path: {} }} }}\n  sinks:\n    o: {{ type: jsonl, config: {{ path: {} }} }}\n  nodes:\n    src: {{ kind: source, ref: s }}\n    w: {{ kind: sink, ref: o }}\n  edges:\n    - {{ from: src, to: w }}\n",
429            csv.display(),
430            out.display()
431        )
432    }
433
434    #[tokio::test]
435    async fn scaffold_config_emits_yaml() {
436        let out = call_tool(
437            &ctx(false),
438            "scaffold_config",
439            &json!({"source":"csv","sink":"jsonl","name":"demo"}),
440        )
441        .await;
442        assert_eq!(out["isError"], false);
443        let text = out["content"][0]["text"].as_str().unwrap();
444        assert!(text.contains("name: demo"));
445        assert!(text.contains("type: csv"));
446        assert!(text.contains("type: jsonl"));
447    }
448
449    #[tokio::test]
450    async fn scaffold_config_missing_arg_errors() {
451        let out = call_tool(&ctx(false), "scaffold_config", &json!({"source":"csv"})).await;
452        assert_eq!(out["isError"], true);
453        assert!(out["content"][0]["text"].as_str().unwrap().contains("sink"));
454    }
455
456    #[tokio::test]
457    async fn validate_config_matrix_ok() {
458        let dir = tempfile::tempdir().unwrap();
459        let out = call_tool(
460            &ctx(false),
461            "validate_config",
462            &json!({ "config": csv_config(dir.path()) }),
463        )
464        .await;
465        assert_eq!(out["isError"], false);
466        let text = out["content"][0]["text"].as_str().unwrap();
467        assert!(text.contains("\"mode\": \"matrix\""));
468        assert!(text.contains("\"valid\": true"));
469    }
470
471    #[tokio::test]
472    async fn validate_config_topology_ok() {
473        let dir = tempfile::tempdir().unwrap();
474        let out = call_tool(
475            &ctx(false),
476            "validate_config",
477            &json!({ "config": topology_config(dir.path()) }),
478        )
479        .await;
480        assert_eq!(out["isError"], false);
481        assert!(
482            out["content"][0]["text"]
483                .as_str()
484                .unwrap()
485                .contains("\"mode\": \"topology\"")
486        );
487    }
488
489    #[tokio::test]
490    async fn validate_config_bad_yaml_errors() {
491        let out = call_tool(
492            &ctx(false),
493            "validate_config",
494            &json!({ "config": "this: is: not: valid: yaml:" }),
495        )
496        .await;
497        assert_eq!(out["isError"], true);
498    }
499
500    #[tokio::test]
501    async fn preview_matrix_returns_records() {
502        let dir = tempfile::tempdir().unwrap();
503        let out = call_tool(
504            &ctx(false),
505            "preview",
506            &json!({ "config": csv_config(dir.path()), "limit": 1 }),
507        )
508        .await;
509        assert_eq!(out["isError"], false);
510        let text = out["content"][0]["text"].as_str().unwrap();
511        assert!(text.contains("\"count\": 1"));
512        assert!(text.contains("alice"));
513    }
514
515    #[tokio::test]
516    async fn preview_topology_returns_sources() {
517        let dir = tempfile::tempdir().unwrap();
518        let out = call_tool(
519            &ctx(false),
520            "preview",
521            &json!({ "config": topology_config(dir.path()) }),
522        )
523        .await;
524        assert_eq!(out["isError"], false);
525        assert!(
526            out["content"][0]["text"]
527                .as_str()
528                .unwrap()
529                .contains("\"sources\"")
530        );
531    }
532
533    #[tokio::test]
534    async fn run_pipeline_dry_run_validates_and_previews() {
535        let dir = tempfile::tempdir().unwrap();
536        let out = call_tool(
537            &ctx(true),
538            "run_pipeline",
539            &json!({ "config": csv_config(dir.path()), "dry_run": true }),
540        )
541        .await;
542        assert_eq!(out["isError"], false);
543        let text = out["content"][0]["text"].as_str().unwrap();
544        assert!(text.contains("-- preview --"));
545    }
546
547    #[tokio::test]
548    async fn run_pipeline_real_writes_sink() {
549        let dir = tempfile::tempdir().unwrap();
550        let cfg = csv_config(dir.path());
551        let out = call_tool(&ctx(true), "run_pipeline", &json!({ "config": cfg })).await;
552        assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
553        assert!(
554            out["content"][0]["text"]
555                .as_str()
556                .unwrap()
557                .contains("\"records_written\": 2")
558        );
559        assert_eq!(
560            std::fs::read_to_string(dir.path().join("out.jsonl"))
561                .unwrap()
562                .lines()
563                .count(),
564            2
565        );
566    }
567
568    #[tokio::test]
569    async fn get_connector_schema_transform_ok() {
570        let out = call_tool(
571            &ctx(false),
572            "get_connector_schema",
573            &json!({"kind":"transform","name":"keys_case"}),
574        )
575        .await;
576        assert_eq!(out["isError"], false);
577    }
578
579    #[tokio::test]
580    async fn get_connector_schema_bad_kind_errors() {
581        let out = call_tool(
582            &ctx(false),
583            "get_connector_schema",
584            &json!({"kind":"weird","name":"x"}),
585        )
586        .await;
587        assert_eq!(out["isError"], true);
588    }
589}