1use 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
14const PREVIEW_MAX: usize = 100;
17
18pub 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
97pub 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 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
200fn parse_config(text: &str) -> Result<PipelineConfig, String> {
202 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 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}