1use super::McpContext;
9use crate::config::PipelineConfig;
10use crate::mcp::protocol::{ToolDef, tool_error, tool_text};
11use serde_json::{Value, json};
12
13const PREVIEW_MAX: usize = 100;
16
17pub fn tool_defs(ctx: &McpContext) -> Vec<ToolDef> {
19 let mut defs = vec![
20 ToolDef {
21 name: "list_connectors",
22 description: "List all compiled-in sources, sinks, transforms, and state stores, each with a one-line description and (for connectors) a conformance tier.",
23 input_schema: json!({
24 "type": "object",
25 "properties": {
26 "kind": { "type": "string", "enum": ["source", "sink", "transform", "state", "all"], "description": "Filter to one category (default: all)." }
27 }
28 }),
29 },
30 ToolDef {
31 name: "get_connector_schema",
32 description: "Return the JSON Schema for a connector or transform's config block.",
33 input_schema: json!({
34 "type": "object",
35 "properties": {
36 "kind": { "type": "string", "enum": ["source", "sink", "transform"] },
37 "name": { "type": "string", "description": "Connector/transform name, e.g. 'rest' or 'keys_case'." }
38 },
39 "required": ["kind", "name"]
40 }),
41 },
42 ToolDef {
43 name: "scaffold_config",
44 description: "Generate a commented YAML pipeline skeleton for a source→sink pair (read-only: returns text, writes nothing).",
45 input_schema: json!({
46 "type": "object",
47 "properties": {
48 "source": { "type": "string", "description": "Source connector kind." },
49 "sink": { "type": "string", "description": "Sink connector kind." },
50 "name": { "type": "string", "description": "Optional pipeline name." }
51 },
52 "required": ["source", "sink"]
53 }),
54 },
55 ];
56 if ctx.allow_config_execution {
62 defs.push(ToolDef {
63 name: "validate_config",
64 description: "Fully validate a pipeline YAML/JSON config (structure, templates, matrix/topology graph). Returns a per-node report or the validation error.",
65 input_schema: json!({
66 "type": "object",
67 "properties": {
68 "config": { "type": "string", "description": "The pipeline config document (YAML or JSON)." }
69 },
70 "required": ["config"]
71 }),
72 });
73 defs.push(ToolDef {
74 name: "preview",
75 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.",
76 input_schema: json!({
77 "type": "object",
78 "properties": {
79 "config": { "type": "string", "description": "The pipeline config document (YAML or JSON)." },
80 "limit": { "type": "integer", "description": "Max rows to return (1–100, default 10)." }
81 },
82 "required": ["config"]
83 }),
84 });
85 }
86 if ctx.allow_mutations {
87 defs.push(ToolDef {
88 name: "run_pipeline",
89 description: "Run a pipeline from an inline config. MUTATING — gated behind --allow-mutations. Pass dry_run:true to validate+preview only.",
90 input_schema: json!({
91 "type": "object",
92 "properties": {
93 "config": { "type": "string" },
94 "dry_run": { "type": "boolean", "description": "If true, validate + preview only; do not write to any sink." }
95 },
96 "required": ["config"]
97 }),
98 });
99 }
100 #[cfg(feature = "templates")]
101 if ctx.templates.is_some() {
102 defs.push(ToolDef {
103 name: "list_templates",
104 description: "List registered pipeline templates (newest version of each, plus its release status) with the typed params each one takes.",
105 input_schema: json!({ "type": "object", "properties": {} }),
106 });
107 defs.push(ToolDef {
108 name: "get_template",
109 description: "Show one registered pipeline template: its declared params, stored config body, and available versions.",
110 input_schema: json!({
111 "type": "object",
112 "properties": {
113 "id": { "type": "string", "description": "Template id." },
114 "version": { "description": "Version: a number, or a named channel. Derived: \"stable\" (the launched version — the default), \"previous\", \"newest\". Assignable: \"dev\", \"test\", \"staging\", \"pre-prod\", \"canary\", \"prod\". Note \"latest\" is deliberately not a channel — use \"stable\" for the current release or \"newest\" for the highest version number.", "oneOf": [{ "type": "integer" }, { "type": "string" }] }
115 },
116 "required": ["id"]
117 }),
118 });
119 if ctx.allow_mutations {
120 defs.push(ToolDef {
121 name: "register_template",
122 description: "Register a config (declaring typed `params:`) as a new pipeline-template version. MUTATING — gated behind --allow-mutations.",
123 input_schema: json!({
124 "type": "object",
125 "properties": {
126 "config": { "type": "string", "description": "The pipeline config document (YAML or JSON), stored verbatim." },
127 "id": { "type": "string", "description": "Template id. Derived from the config's `name:` when omitted." },
128 "description": { "type": "string" },
129 "tags": { "type": "array", "items": { "type": "string" }, "description": "Named environment channels to point at the new version (dev/test/staging/pre-prod/canary/prod). Derived channels (stable/previous/newest) are rejected." },
130 "launch": { "type": "boolean", "description": "Make the new version live immediately. Off by default: a register is inert, so a new build never moves existing callers until it is launched." }
131 },
132 "required": ["config"]
133 }),
134 });
135 defs.push(ToolDef {
136 name: "launch_template",
137 description: "Make a template version live — what unpinned runs will use. MUTATING. This is the only action that moves existing callers; registering a build does not.",
138 input_schema: json!({
139 "type": "object",
140 "properties": {
141 "id": { "type": "string" },
142 "version": { "description": "Version to launch: a number, or a channel whose current target to copy. Defaults to \"newest\".", "oneOf": [{ "type": "integer" }, { "type": "string" }] }
143 },
144 "required": ["id"]
145 }),
146 });
147 defs.push(ToolDef {
148 name: "rollback_template",
149 description: "Re-launch a template's previously launched version. MUTATING.",
150 input_schema: json!({
151 "type": "object",
152 "properties": { "id": { "type": "string" } },
153 "required": ["id"]
154 }),
155 });
156 defs.push(ToolDef {
157 name: "deprecate_template",
158 description: "Retire a template (or revive it with undo:true). MUTATING. A deprecated template keeps serving existing callers but every trigger warns.",
159 input_schema: json!({
160 "type": "object",
161 "properties": {
162 "id": { "type": "string" },
163 "reason": { "type": "string" },
164 "undo": { "type": "boolean" }
165 },
166 "required": ["id"]
167 }),
168 });
169 defs.push(ToolDef {
170 name: "run_template",
171 description: "Run a registered pipeline template with the given params. MUTATING — gated behind --allow-mutations. Pass dry_run:true to materialize + validate only.",
172 input_schema: json!({
173 "type": "object",
174 "properties": {
175 "id": { "type": "string" },
176 "version": { "description": "Version: a number, or a named channel. Derived: \"stable\" (the launched version — the default), \"previous\", \"newest\". Assignable: \"dev\", \"test\", \"staging\", \"pre-prod\", \"canary\", \"prod\". Note \"latest\" is deliberately not a channel — use \"stable\" for the current release or \"newest\" for the highest version number.", "oneOf": [{ "type": "integer" }, { "type": "string" }] },
177 "params": { "type": "object", "description": "Values for the template's declared params." },
178 "env": { "type": "object", "description": "Per-run overrides for ${env:VAR} resolution." },
179 "dry_run": { "type": "boolean", "description": "If true, materialize + validate only; do not write to any sink." }
180 },
181 "required": ["id"]
182 }),
183 });
184 }
185 }
186 defs
187}
188
189pub async fn call_tool(ctx: &McpContext, name: &str, args: &Value) -> Value {
193 let result: Result<String, String> = match name {
194 "list_connectors" => list_connectors(args),
195 "get_connector_schema" => get_connector_schema(args),
196 "scaffold_config" => scaffold_config(args),
197 "validate_config" => {
200 if !ctx.allow_config_execution {
201 Err(CONFIG_EXEC_GATE.to_string())
202 } else {
203 validate_config(ctx, args).await
204 }
205 }
206 "preview" => {
207 if !ctx.allow_config_execution {
208 Err(CONFIG_EXEC_GATE.to_string())
209 } else {
210 preview(ctx, args).await
211 }
212 }
213 "run_pipeline" => {
214 if !ctx.allow_mutations {
215 Err("run_pipeline is disabled; start the MCP server with --allow-mutations to enable mutating tools".to_string())
216 } else {
217 run_pipeline(ctx, args).await
218 }
219 }
220 #[cfg(feature = "templates")]
221 "list_templates" => list_templates(ctx).await,
222 #[cfg(feature = "templates")]
223 "get_template" => get_template(ctx, args).await,
224 #[cfg(feature = "templates")]
225 "register_template" => {
226 if !ctx.allow_mutations {
227 Err(MUTATION_GATE.to_string())
228 } else {
229 register_template(ctx, args).await
230 }
231 }
232 #[cfg(feature = "templates")]
233 "launch_template" => {
234 if !ctx.allow_mutations {
235 Err(MUTATION_GATE.to_string())
236 } else {
237 launch_template(ctx, args).await
238 }
239 }
240 #[cfg(feature = "templates")]
241 "rollback_template" => {
242 if !ctx.allow_mutations {
243 Err(MUTATION_GATE.to_string())
244 } else {
245 rollback_template(ctx, args).await
246 }
247 }
248 #[cfg(feature = "templates")]
249 "deprecate_template" => {
250 if !ctx.allow_mutations {
251 Err(MUTATION_GATE.to_string())
252 } else {
253 deprecate_template(ctx, args).await
254 }
255 }
256 #[cfg(feature = "templates")]
257 "run_template" => {
258 if !ctx.allow_mutations {
259 Err(MUTATION_GATE.to_string())
260 } else {
261 run_template(ctx, args).await
262 }
263 }
264 other => Err(format!("unknown tool '{other}'")),
265 };
266 match result {
267 Ok(text) => tool_text(text),
268 Err(msg) => tool_error(crate::secrets::registry::redact(&msg)),
270 }
271}
272
273fn str_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
274 args.get(key)
275 .and_then(Value::as_str)
276 .ok_or_else(|| format!("missing required string argument '{key}'"))
277}
278
279fn tier_of(kind: &str, is_source: bool) -> &'static str {
280 crate::conformance::tier_for(kind, is_source).as_str()
281}
282
283fn list_connectors(args: &Value) -> Result<String, String> {
284 let filter = args.get("kind").and_then(Value::as_str).unwrap_or("all");
285 let want = |c: &str| filter == "all" || filter == c;
286
287 let mut out = json!({});
288 let obj = out.as_object_mut().unwrap();
289 if want("source") {
290 let sources: Vec<Value> = crate::registry::source_descriptions()
291 .into_iter()
292 .map(|(name, desc)| json!({ "name": name, "description": desc, "tier": tier_of(name, true) }))
293 .collect();
294 obj.insert("sources".into(), json!(sources));
295 }
296 if want("sink") {
297 let sinks: Vec<Value> = crate::registry::sink_descriptions()
298 .into_iter()
299 .map(|(name, desc)| json!({ "name": name, "description": desc, "tier": tier_of(name, false) }))
300 .collect();
301 obj.insert("sinks".into(), json!(sinks));
302 }
303 if want("transform") {
304 let transforms: Vec<Value> = crate::transforms::transform_descriptions()
305 .into_iter()
306 .map(|(name, desc)| json!({ "name": name, "description": desc }))
307 .collect();
308 obj.insert("transforms".into(), json!(transforms));
309 }
310 if want("state") {
311 obj.insert(
312 "state_stores".into(),
313 json!(crate::state::available_state_kinds()),
314 );
315 }
316 Ok(pretty(&out))
317}
318
319fn get_connector_schema(args: &Value) -> Result<String, String> {
320 let kind = str_arg(args, "kind")?;
321 let name = str_arg(args, "name")?;
322 let schema = match kind {
323 "source" => crate::registry::source_schema(name),
324 "sink" => crate::registry::sink_schema(name),
325 "transform" => crate::transforms::transform_schema(name),
326 other => return Err(format!("kind must be source|sink|transform, got '{other}'")),
327 }
328 .map_err(|e| e.to_string())?;
329 Ok(pretty(&schema))
330}
331
332fn scaffold_config(args: &Value) -> Result<String, String> {
333 let source = str_arg(args, "source")?;
334 let sink = str_arg(args, "sink")?;
335 let name = args
336 .get("name")
337 .and_then(Value::as_str)
338 .unwrap_or("pipeline");
339
340 let src_schema = crate::registry::source_schema(source).map_err(|e| e.to_string())?;
341 let sink_schema = crate::registry::sink_schema(sink).map_err(|e| e.to_string())?;
342 let src_yaml = crate::init_template::schema_to_yaml_template(&src_schema, 6);
343 let sink_yaml = crate::init_template::schema_to_yaml_template(&sink_schema, 6);
344
345 Ok(format!(
346 "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}"
347 ))
348}
349
350fn parse_config_with(text: &str, mode: crate::params::BindMode) -> Result<PipelineConfig, String> {
359 let mut doc: Value = serde_yaml::from_str(text).map_err(|e| e.to_string())?;
360 crate::interpolate::interpolate_value(&mut doc).map_err(|e| e.to_string())?;
361 crate::params::bind_document(&mut doc, &Default::default(), mode).map_err(|e| e.to_string())?;
362 PipelineConfig::from_value(doc).map_err(|e| e.to_string())
363}
364
365fn parse_config(text: &str) -> Result<PipelineConfig, String> {
368 parse_config_with(text, crate::params::BindMode::Placeholder)
369}
370
371async fn validate_config(ctx: &McpContext, args: &Value) -> Result<String, String> {
372 let text = str_arg(args, "config")?;
373 let cfg = parse_config(text)?;
374
375 if crate::topology::is_topology(&cfg) {
376 let topo = crate::topology::build_topology(&cfg, &ctx.auth)
377 .await
378 .map_err(|e| e.to_string())?;
379 return Ok(pretty(&json!({
380 "valid": true,
381 "mode": "topology",
382 "nodes": topo.nodes().iter().map(|n| json!({"id": n.id, "kind": n.kind.kind_str()})).collect::<Vec<_>>(),
383 "edges": topo.edges().len(),
384 })));
385 }
386
387 let nodes = crate::expand::expand(&cfg).map_err(|e| e.to_string())?;
388 let rows: Vec<Value> = nodes
389 .iter()
390 .map(|n| {
391 json!({
392 "id": n.id,
393 "source": n.source.kind,
394 "sink": n.sink.kind,
395 "transforms": n.transforms.len(),
396 })
397 })
398 .collect();
399 Ok(pretty(&json!({
400 "valid": true,
401 "mode": "matrix",
402 "name": cfg.name,
403 "rows": rows,
404 })))
405}
406
407async fn preview(ctx: &McpContext, args: &Value) -> Result<String, String> {
408 use faucet_core::stage::{apply_stages, compile_stage};
409
410 let text = str_arg(args, "config")?;
411 let limit = args
412 .get("limit")
413 .and_then(Value::as_u64)
414 .map(|n| (n as usize).clamp(1, PREVIEW_MAX))
415 .unwrap_or(10);
416
417 let cfg = parse_config(text)?;
418 if crate::topology::is_topology(&cfg) {
419 return crate::topology::preview_to_string(&cfg, &ctx.auth, limit)
420 .await
421 .map_err(|e| e.to_string());
422 }
423
424 let nodes = crate::expand::expand(&cfg).map_err(|e| e.to_string())?;
425 let first_root = nodes
426 .iter()
427 .find(|n| matches!(n.role, crate::expand::NodeRole::Root))
428 .ok_or_else(|| "no root row to preview".to_string())?;
429
430 let source = crate::registry::build_source(
431 &first_root.source.kind,
432 first_root.source.config.clone(),
433 &ctx.auth,
434 None,
435 )
436 .await
437 .map_err(|e| e.to_string())?;
438 let stages =
439 crate::transforms::compile_transforms(&first_root.transforms).map_err(|e| e.to_string())?;
440 let records = source.fetch_all().await.map_err(|e| e.to_string())?;
441 let records: Vec<Value> = if stages.is_empty() {
442 records
443 } else {
444 let compiled = stages
445 .iter()
446 .map(compile_stage)
447 .collect::<Result<Vec<_>, _>>()
448 .map_err(|e| e.to_string())?;
449 let mut out = Vec::with_capacity(records.len());
450 for r in records {
451 out.extend(apply_stages(r, &compiled).map_err(|e| e.to_string())?);
452 }
453 out
454 };
455 let limited: Vec<Value> = records.into_iter().take(limit).collect();
456 Ok(pretty(
457 &json!({ "row": first_root.id, "count": limited.len(), "records": limited }),
458 ))
459}
460
461async fn run_pipeline(ctx: &McpContext, args: &Value) -> Result<String, String> {
462 let text = str_arg(args, "config")?;
463 let dry_run = args
464 .get("dry_run")
465 .and_then(Value::as_bool)
466 .unwrap_or(false);
467
468 if dry_run {
469 let mut report = validate_config(ctx, args).await?;
470 report.push_str("\n\n-- preview --\n");
471 report.push_str(
472 &preview(ctx, args)
473 .await
474 .unwrap_or_else(|e| format!("preview skipped: {e}")),
475 );
476 return Ok(report);
477 }
478
479 let summary = crate::run_from_yaml_str(text)
480 .await
481 .map_err(|e| e.to_string())?;
482 let failed = summary.failure_count();
483 let total: usize = summary.invocations.iter().map(|i| i.records_written).sum();
484 let doc = json!({
485 "invocations": summary.invocations.len(),
486 "ok": summary.invocations.len() - failed,
487 "failed": failed,
488 "records_written": total,
489 });
490 if failed > 0 {
491 return Err(format!(
492 "pipeline had {failed} failed invocation(s): {}",
493 pretty(&doc)
494 ));
495 }
496 Ok(pretty(&doc))
497}
498
499fn pretty(v: &Value) -> String {
500 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
501}
502
503#[cfg(feature = "templates")]
507const MUTATION_GATE: &str =
508 "this tool is disabled; start the MCP server with --allow-mutations to enable mutating tools";
509
510const CONFIG_EXEC_GATE: &str = "this tool acts on a config you supply — it resolves \
513 ${env:}/${file:}/${secret:} on the server and builds the connectors you name — so it \
514 requires the same scope as POST /v1/doctor (role `operator` or `admin`), not a read-only \
515 token";
516
517#[cfg(feature = "templates")]
519fn template_store(ctx: &McpContext) -> Result<&crate::templates::TemplateStore, String> {
520 ctx.templates.as_ref().ok_or_else(|| {
521 "no pipeline-template registry is configured — start `faucet mcp --template-store \
522 <url>`, or use the /mcp route of a `faucet serve --mcp` whose --history backend holds \
523 the registry"
524 .to_string()
525 })
526}
527
528#[cfg(feature = "templates")]
529async fn list_templates(ctx: &McpContext) -> Result<String, String> {
530 let store = template_store(ctx)?;
531 let templates = crate::templates::list_with_state(store)
532 .await
533 .map_err(|e| e.to_string())?;
534 Ok(pretty(&json!({
535 "count": templates.len(),
536 "templates": templates,
537 })))
538}
539
540#[cfg(feature = "templates")]
545fn version_arg(args: &Value) -> Result<crate::serve::history::templates::VersionSelector, String> {
546 use crate::serve::history::templates::VersionSelector;
547 match args.get("version") {
548 None | Some(Value::Null) => Ok(VersionSelector::default()),
549 Some(v) => serde_json::from_value::<VersionSelector>(v.clone()).map_err(|e| e.to_string()),
550 }
551}
552
553#[cfg(feature = "templates")]
556async fn resolved_version_arg(
557 store: &crate::templates::TemplateStore,
558 id: &str,
559 args: &Value,
560) -> Result<u32, String> {
561 crate::templates::resolve_version(store, id, version_arg(args)?)
562 .await
563 .map_err(|e| e.to_string())
564}
565
566#[cfg(feature = "templates")]
567async fn get_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
568 let store = template_store(ctx)?;
569 let id = str_arg(args, "id")?;
570 let version = resolved_version_arg(store, id, args).await?;
571 let record = store
572 .template_get(id, Some(version))
573 .await
574 .map_err(|e| e.to_string())?
575 .ok_or_else(|| format!("no pipeline template '{id}'"))?;
576 let launches = store
577 .template_launches(id)
578 .await
579 .map_err(|e| e.to_string())?;
580 let state = crate::templates::template_state(store, id)
581 .await
582 .map_err(|e| e.to_string())?;
583 Ok(pretty(&json!({
584 "template": record,
585 "state": state,
586 "is_stable": state.stable == Some(record.version),
587 "launches": launches,
588 })))
589}
590
591#[cfg(feature = "templates")]
593fn tags_arg(args: &Value) -> Result<Vec<crate::serve::history::templates::VersionChannel>, String> {
594 use crate::serve::history::templates::VersionChannel;
595 let Some(list) = args.get("tags").and_then(Value::as_array) else {
596 return Ok(Vec::new());
597 };
598 list.iter()
599 .map(|v| {
600 v.as_str()
601 .ok_or_else(|| "each `tags` entry must be a channel name".to_string())
602 .and_then(|s| VersionChannel::parse(s).map_err(|e| e.to_string()))
603 })
604 .collect()
605}
606
607#[cfg(feature = "templates")]
608async fn register_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
609 use crate::templates::RegisterRequest;
610 let store = template_store(ctx)?;
611 let config = str_arg(args, "config")?;
612 let record = crate::templates::register(
613 store,
614 RegisterRequest {
615 id: args.get("id").and_then(Value::as_str).map(str::to_string),
616 body: config.to_string(),
617 format: crate::serve::load::ConfigFormat::Yaml,
619 description: args
620 .get("description")
621 .and_then(Value::as_str)
622 .map(str::to_string),
623 tags: tags_arg(args)?,
624 launch: args.get("launch").and_then(Value::as_bool).unwrap_or(false),
625 created_by: Some("mcp".to_string()),
626 },
627 )
628 .await
629 .map_err(|e| e.to_string())?;
630 Ok(pretty(&json!({
631 "registered": record.summary(),
632 })))
633}
634
635#[cfg(feature = "templates")]
636async fn launch_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
637 use crate::serve::history::templates::VersionSelector;
638 let store = template_store(ctx)?;
639 let id = str_arg(args, "id")?;
640 let target = match args.get("version") {
641 None | Some(Value::Null) => VersionSelector::newest(),
642 Some(_) => version_arg(args)?,
643 };
644 let outcome = crate::templates::launch(store, id, target, Some("mcp"))
645 .await
646 .map_err(|e| e.to_string())?;
647 Ok(pretty(&json!({
648 "id": id,
649 "version": outcome.version,
650 "replaced": outcome.replaced,
651 "already_launched": outcome.already_launched,
652 "first_launch": outcome.first_launch,
653 })))
654}
655
656#[cfg(feature = "templates")]
657async fn rollback_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
658 let store = template_store(ctx)?;
659 let id = str_arg(args, "id")?;
660 let outcome = crate::templates::rollback(store, id, Some("mcp"))
661 .await
662 .map_err(|e| e.to_string())?;
663 Ok(pretty(&json!({
664 "id": id,
665 "version": outcome.version,
666 "replaced": outcome.replaced,
667 })))
668}
669
670#[cfg(feature = "templates")]
671async fn deprecate_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
672 let store = template_store(ctx)?;
673 let id = str_arg(args, "id")?;
674 let undo = args.get("undo").and_then(Value::as_bool).unwrap_or(false);
675 let reason = args
676 .get("reason")
677 .and_then(Value::as_str)
678 .map(str::to_string);
679 let status = crate::templates::set_deprecated(store, id, reason, Some("mcp"), !undo)
680 .await
681 .map_err(|e| e.to_string())?;
682 Ok(pretty(&json!({ "id": id, "status": status.as_str() })))
683}
684
685#[cfg(feature = "templates")]
686async fn run_template(ctx: &McpContext, args: &Value) -> Result<String, String> {
687 let store = template_store(ctx)?;
688 let id = str_arg(args, "id")?;
689 let version = resolved_version_arg(store, id, args).await?;
690 let dry_run = args
691 .get("dry_run")
692 .and_then(Value::as_bool)
693 .unwrap_or(false);
694 let supplied: crate::params::SuppliedParams = args
695 .get("params")
696 .and_then(Value::as_object)
697 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
698 .unwrap_or_default();
699 let env: std::collections::BTreeMap<String, String> = args
700 .get("env")
701 .and_then(Value::as_object)
702 .map(|m| {
703 m.iter()
704 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
705 .collect()
706 })
707 .unwrap_or_default();
708
709 let materialized = crate::templates::materialize(
710 store,
711 id,
712 version,
713 &supplied,
714 &env,
715 crate::templates::Materialize::Local,
717 )
718 .await
719 .map_err(|e| e.to_string())?;
720
721 if dry_run {
722 let cfg = parse_config_with(&materialized.body, crate::params::BindMode::Strict)?;
725 let rows = crate::expand::expand(&cfg)
726 .map_err(|e| e.to_string())?
727 .len();
728 return Ok(pretty(&json!({
729 "template_id": materialized.template_id,
730 "template_version": materialized.version,
731 "params": materialized.params_redacted,
732 "rows": rows,
733 "dry_run": true,
734 })));
735 }
736
737 let summary = crate::run_from_yaml_str(&materialized.body)
740 .await
741 .map_err(|e| e.to_string())?;
742 let failed = summary.failure_count();
743 let total: usize = summary.invocations.iter().map(|i| i.records_written).sum();
744 let doc = json!({
745 "template_id": materialized.template_id,
746 "template_version": materialized.version,
747 "params": materialized.params_redacted,
748 "invocations": summary.invocations.len(),
749 "ok": summary.invocations.len() - failed,
750 "failed": failed,
751 "records_written": total,
752 });
753 if failed > 0 {
754 return Err(format!(
755 "template run had {failed} failed invocation(s): {}",
756 pretty(&doc)
757 ));
758 }
759 Ok(pretty(&doc))
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use crate::mcp::McpContext;
766
767 fn ctx(allow: bool) -> McpContext {
768 McpContext::new(
769 crate::auth_catalog::build_auth_catalog(None).unwrap(),
770 allow,
771 )
772 }
773
774 #[test]
775 fn tool_defs_gate_mutations() {
776 let ro = tool_defs(&ctx(false));
777 assert!(ro.iter().all(|t| t.name != "run_pipeline"));
778 let rw = tool_defs(&ctx(true));
779 assert!(rw.iter().any(|t| t.name == "run_pipeline"));
780 }
781
782 #[tokio::test]
783 async fn list_connectors_includes_sources_and_tier() {
784 let out = call_tool(&ctx(false), "list_connectors", &json!({})).await;
785 assert_eq!(out["isError"], false);
786 let text = out["content"][0]["text"].as_str().unwrap();
787 assert!(text.contains("\"sources\""));
788 assert!(text.contains("\"tier\""));
789 }
790
791 #[tokio::test]
792 async fn list_connectors_filter_kind() {
793 let out = call_tool(
794 &ctx(false),
795 "list_connectors",
796 &json!({"kind": "transform"}),
797 )
798 .await;
799 let text = out["content"][0]["text"].as_str().unwrap();
800 assert!(text.contains("\"transforms\""));
801 assert!(!text.contains("\"sources\""));
802 }
803
804 #[tokio::test]
805 async fn get_connector_schema_unknown_is_tool_error() {
806 let out = call_tool(
807 &ctx(false),
808 "get_connector_schema",
809 &json!({"kind":"source","name":"nope"}),
810 )
811 .await;
812 assert_eq!(out["isError"], true);
813 }
814
815 #[tokio::test]
816 async fn unknown_tool_errors() {
817 let out = call_tool(&ctx(false), "does_not_exist", &json!({})).await;
818 assert_eq!(out["isError"], true);
819 }
820
821 #[tokio::test]
822 async fn run_pipeline_blocked_without_mutations() {
823 let out = call_tool(&ctx(false), "run_pipeline", &json!({"config":"version: 1"})).await;
824 assert_eq!(out["isError"], true);
825 assert!(
826 out["content"][0]["text"]
827 .as_str()
828 .unwrap()
829 .contains("--allow-mutations")
830 );
831 }
832
833 fn csv_config(dir: &std::path::Path) -> String {
836 let csv = dir.join("in.csv");
837 std::fs::write(&csv, "id,name\n1,alice\n2,bob\n").unwrap();
838 let out = dir.join("out.jsonl");
839 format!(
840 "version: 1\nname: t\npipeline:\n source:\n type: csv\n config:\n path: {}\n sink:\n type: jsonl\n config:\n path: {}\n",
841 csv.display(),
842 out.display()
843 )
844 }
845
846 fn topology_config(dir: &std::path::Path) -> String {
847 let csv = dir.join("in.csv");
848 std::fs::write(&csv, "id,name\n1,alice\n").unwrap();
849 let out = dir.join("out.jsonl");
850 format!(
851 "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",
852 csv.display(),
853 out.display()
854 )
855 }
856
857 #[tokio::test]
858 async fn scaffold_config_emits_yaml() {
859 let out = call_tool(
860 &ctx(false),
861 "scaffold_config",
862 &json!({"source":"csv","sink":"jsonl","name":"demo"}),
863 )
864 .await;
865 assert_eq!(out["isError"], false);
866 let text = out["content"][0]["text"].as_str().unwrap();
867 assert!(text.contains("name: demo"));
868 assert!(text.contains("type: csv"));
869 assert!(text.contains("type: jsonl"));
870 }
871
872 #[tokio::test]
873 async fn scaffold_config_missing_arg_errors() {
874 let out = call_tool(&ctx(false), "scaffold_config", &json!({"source":"csv"})).await;
875 assert_eq!(out["isError"], true);
876 assert!(out["content"][0]["text"].as_str().unwrap().contains("sink"));
877 }
878
879 #[tokio::test]
880 async fn validate_config_matrix_ok() {
881 let dir = tempfile::tempdir().unwrap();
882 let out = call_tool(
883 &ctx(false),
884 "validate_config",
885 &json!({ "config": csv_config(dir.path()) }),
886 )
887 .await;
888 assert_eq!(out["isError"], false);
889 let text = out["content"][0]["text"].as_str().unwrap();
890 assert!(text.contains("\"mode\": \"matrix\""));
891 assert!(text.contains("\"valid\": true"));
892 }
893
894 #[tokio::test]
895 async fn validate_config_topology_ok() {
896 let dir = tempfile::tempdir().unwrap();
897 let out = call_tool(
898 &ctx(false),
899 "validate_config",
900 &json!({ "config": topology_config(dir.path()) }),
901 )
902 .await;
903 assert_eq!(out["isError"], false);
904 assert!(
905 out["content"][0]["text"]
906 .as_str()
907 .unwrap()
908 .contains("\"mode\": \"topology\"")
909 );
910 }
911
912 #[tokio::test]
913 async fn validate_config_bad_yaml_errors() {
914 let out = call_tool(
915 &ctx(false),
916 "validate_config",
917 &json!({ "config": "this: is: not: valid: yaml:" }),
918 )
919 .await;
920 assert_eq!(out["isError"], true);
921 }
922
923 #[tokio::test]
924 async fn preview_matrix_returns_records() {
925 let dir = tempfile::tempdir().unwrap();
926 let out = call_tool(
927 &ctx(false),
928 "preview",
929 &json!({ "config": csv_config(dir.path()), "limit": 1 }),
930 )
931 .await;
932 assert_eq!(out["isError"], false);
933 let text = out["content"][0]["text"].as_str().unwrap();
934 assert!(text.contains("\"count\": 1"));
935 assert!(text.contains("alice"));
936 }
937
938 #[tokio::test]
939 async fn preview_topology_returns_sources() {
940 let dir = tempfile::tempdir().unwrap();
941 let out = call_tool(
942 &ctx(false),
943 "preview",
944 &json!({ "config": topology_config(dir.path()) }),
945 )
946 .await;
947 assert_eq!(out["isError"], false);
948 assert!(
949 out["content"][0]["text"]
950 .as_str()
951 .unwrap()
952 .contains("\"sources\"")
953 );
954 }
955
956 #[tokio::test]
957 async fn run_pipeline_dry_run_validates_and_previews() {
958 let dir = tempfile::tempdir().unwrap();
959 let out = call_tool(
960 &ctx(true),
961 "run_pipeline",
962 &json!({ "config": csv_config(dir.path()), "dry_run": true }),
963 )
964 .await;
965 assert_eq!(out["isError"], false);
966 let text = out["content"][0]["text"].as_str().unwrap();
967 assert!(text.contains("-- preview --"));
968 }
969
970 #[tokio::test]
971 async fn run_pipeline_real_writes_sink() {
972 let dir = tempfile::tempdir().unwrap();
973 let cfg = csv_config(dir.path());
974 let out = call_tool(&ctx(true), "run_pipeline", &json!({ "config": cfg })).await;
975 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
976 assert!(
977 out["content"][0]["text"]
978 .as_str()
979 .unwrap()
980 .contains("\"records_written\": 2")
981 );
982 assert_eq!(
983 std::fs::read_to_string(dir.path().join("out.jsonl"))
984 .unwrap()
985 .lines()
986 .count(),
987 2
988 );
989 }
990
991 #[tokio::test]
992 async fn get_connector_schema_transform_ok() {
993 let out = call_tool(
994 &ctx(false),
995 "get_connector_schema",
996 &json!({"kind":"transform","name":"keys_case"}),
997 )
998 .await;
999 assert_eq!(out["isError"], false);
1000 }
1001
1002 #[tokio::test]
1003 async fn get_connector_schema_bad_kind_errors() {
1004 let out = call_tool(
1005 &ctx(false),
1006 "get_connector_schema",
1007 &json!({"kind":"weird","name":"x"}),
1008 )
1009 .await;
1010 assert_eq!(out["isError"], true);
1011 }
1012
1013 #[tokio::test]
1014 async fn validate_config_accepts_a_parameterized_config() {
1015 let dir = tempfile::tempdir().unwrap();
1018 let cfg = format!(
1019 "version: 1\nname: t\nparams:\n tag: {{ required: true }}\npipeline:\n source:\n type: csv\n config:\n path: {}\n sink:\n type: jsonl\n config:\n path: {}\n",
1020 dir.path().join("in-${param.tag}.csv").display(),
1021 dir.path().join("out.jsonl").display()
1022 );
1023 let out = call_tool(&ctx(false), "validate_config", &json!({ "config": cfg })).await;
1024 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1025 }
1026
1027 #[cfg(feature = "templates")]
1030 mod templates {
1031 use super::*;
1032 use std::sync::Arc;
1033 use std::time::Duration;
1034
1035 fn tpl_ctx(allow: bool) -> McpContext {
1036 let store = Arc::new(crate::serve::history::memory::MemoryHistory::new(
1037 Duration::from_secs(60),
1038 )) as crate::templates::TemplateStore;
1039 McpContext::new(
1040 crate::auth_catalog::build_auth_catalog(None).unwrap(),
1041 allow,
1042 )
1043 .with_templates(store)
1044 }
1045
1046 fn body(dir: &std::path::Path) -> String {
1047 let csv = dir.join("in.csv");
1048 std::fs::write(&csv, "id,name\n1,alice\n2,bob\n").unwrap();
1049 format!(
1050 "version: 1\nname: mcp-tpl\nparams:\n tag: {{ required: true }}\npipeline:\n source:\n type: csv\n config:\n path: {}\n sink:\n type: jsonl\n config:\n path: {}\n",
1051 csv.display(),
1052 dir.join("out-${param.tag}.jsonl").display()
1053 )
1054 }
1055
1056 #[tokio::test]
1057 async fn tools_are_hidden_without_a_store() {
1058 let names: Vec<&str> = tool_defs(&ctx(true)).iter().map(|t| t.name).collect();
1059 for t in ["list_templates", "register_template", "launch_template"] {
1060 assert!(!names.contains(&t), "{t} must be hidden: {names:?}");
1061 }
1062 let out = call_tool(&ctx(true), "list_templates", &json!({})).await;
1064 assert_eq!(out["isError"], true);
1065 assert!(
1066 out["content"][0]["text"]
1067 .as_str()
1068 .unwrap()
1069 .contains("--template-store")
1070 );
1071 }
1072
1073 #[tokio::test]
1074 async fn read_tools_are_ungated_and_write_tools_are_gated() {
1075 let ro: Vec<&str> = tool_defs(&tpl_ctx(false)).iter().map(|t| t.name).collect();
1076 for t in ["list_templates", "get_template"] {
1077 assert!(ro.contains(&t), "{t} should be read-only: {ro:?}");
1078 }
1079 let mutating = [
1080 "register_template",
1081 "run_template",
1082 "launch_template",
1083 "rollback_template",
1084 "deprecate_template",
1085 ];
1086 for t in mutating {
1087 assert!(!ro.contains(&t), "{t} must be gated: {ro:?}");
1088 }
1089 let rw: Vec<&str> = tool_defs(&tpl_ctx(true)).iter().map(|t| t.name).collect();
1090 for t in mutating {
1091 assert!(rw.contains(&t), "{t} should appear with mutations: {rw:?}");
1092 let out = call_tool(&tpl_ctx(false), t, &json!({"id":"x","config":"y"})).await;
1093 assert_eq!(out["isError"], true, "{t} must be gated");
1094 assert!(
1095 out["content"][0]["text"]
1096 .as_str()
1097 .unwrap()
1098 .contains("--allow-mutations")
1099 );
1100 }
1101 }
1102
1103 #[tokio::test]
1104 async fn register_list_get_and_run_round_trip() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let ctx = tpl_ctx(true);
1107
1108 let out = call_tool(
1110 &ctx,
1111 "register_template",
1112 &json!({ "config": body(dir.path()), "launch": true }),
1113 )
1114 .await;
1115 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1116 assert!(
1117 out["content"][0]["text"]
1118 .as_str()
1119 .unwrap()
1120 .contains("mcp-tpl")
1121 );
1122
1123 let out = call_tool(&ctx, "list_templates", &json!({})).await;
1124 let text = out["content"][0]["text"].as_str().unwrap();
1125 assert!(text.contains("\"count\": 1"), "{text}");
1126 assert!(text.contains("\"launched\""), "status is surfaced: {text}");
1127
1128 let out = call_tool(&ctx, "get_template", &json!({"id":"mcp-tpl"})).await;
1129 assert_eq!(out["isError"], false);
1130 let text = out["content"][0]["text"].as_str().unwrap();
1131 assert!(text.contains("\"launches\""), "{text}");
1132 assert!(text.contains("${param.tag}"), "body is verbatim: {text}");
1133
1134 let out = call_tool(&ctx, "get_template", &json!({"id":"nope"})).await;
1135 assert_eq!(out["isError"], true);
1136
1137 let out = call_tool(&ctx, "run_template", &json!({"id":"mcp-tpl"})).await;
1139 assert_eq!(out["isError"], true);
1140 assert!(out["content"][0]["text"].as_str().unwrap().contains("tag"));
1141
1142 let out = call_tool(
1144 &ctx,
1145 "run_template",
1146 &json!({"id":"mcp-tpl","params":{"tag":"dry"},"dry_run":true}),
1147 )
1148 .await;
1149 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1150 assert!(
1151 out["content"][0]["text"]
1152 .as_str()
1153 .unwrap()
1154 .contains("\"dry_run\": true")
1155 );
1156 assert!(!dir.path().join("out-dry.jsonl").exists());
1157
1158 let out = call_tool(
1160 &ctx,
1161 "run_template",
1162 &json!({"id":"mcp-tpl","params":{"tag":"real"}}),
1163 )
1164 .await;
1165 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1166 assert!(
1167 out["content"][0]["text"]
1168 .as_str()
1169 .unwrap()
1170 .contains("\"records_written\": 2")
1171 );
1172 assert_eq!(
1173 std::fs::read_to_string(dir.path().join("out-real.jsonl"))
1174 .unwrap()
1175 .lines()
1176 .count(),
1177 2
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn a_draft_template_is_not_runnable_unpinned() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let ctx = tpl_ctx(true);
1185 call_tool(
1187 &ctx,
1188 "register_template",
1189 &json!({ "config": body(dir.path()) }),
1190 )
1191 .await;
1192
1193 let out = call_tool(
1194 &ctx,
1195 "run_template",
1196 &json!({"id":"mcp-tpl","params":{"tag":"x"},"dry_run":true}),
1197 )
1198 .await;
1199 assert_eq!(out["isError"], true);
1200 let text = out["content"][0]["text"].as_str().unwrap();
1201 assert!(text.contains("no launched version"), "{text}");
1202
1203 let out = call_tool(
1205 &ctx,
1206 "run_template",
1207 &json!({"id":"mcp-tpl","params":{"tag":"x"},"version":"newest","dry_run":true}),
1208 )
1209 .await;
1210 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1211 }
1212
1213 #[tokio::test]
1214 async fn launch_rollback_and_deprecate_tools() {
1215 let dir = tempfile::tempdir().unwrap();
1216 let ctx = tpl_ctx(true);
1217 call_tool(
1218 &ctx,
1219 "register_template",
1220 &json!({ "config": body(dir.path()), "launch": true }),
1221 )
1222 .await; call_tool(
1224 &ctx,
1225 "register_template",
1226 &json!({ "config": body(dir.path()) }),
1227 )
1228 .await; let out = call_tool(&ctx, "launch_template", &json!({"id":"mcp-tpl"})).await;
1232 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1233 let text = out["content"][0]["text"].as_str().unwrap();
1234 assert!(text.contains("\"version\": 2"), "{text}");
1235 assert!(text.contains("\"replaced\": 1"), "{text}");
1236
1237 let out = call_tool(&ctx, "rollback_template", &json!({"id":"mcp-tpl"})).await;
1239 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1240 assert!(
1241 out["content"][0]["text"]
1242 .as_str()
1243 .unwrap()
1244 .contains("\"version\": 1")
1245 );
1246
1247 let out = call_tool(
1249 &ctx,
1250 "deprecate_template",
1251 &json!({"id":"mcp-tpl","reason":"superseded"}),
1252 )
1253 .await;
1254 assert!(
1255 out["content"][0]["text"]
1256 .as_str()
1257 .unwrap()
1258 .contains("deprecated")
1259 );
1260 let out = call_tool(&ctx, "launch_template", &json!({"id":"mcp-tpl"})).await;
1262 assert_eq!(out["isError"], true);
1263 let out = call_tool(
1264 &ctx,
1265 "deprecate_template",
1266 &json!({"id":"mcp-tpl","undo":true}),
1267 )
1268 .await;
1269 assert!(
1270 out["content"][0]["text"]
1271 .as_str()
1272 .unwrap()
1273 .contains("launched")
1274 );
1275 }
1276
1277 #[tokio::test]
1278 async fn register_with_tags_and_run_by_channel() {
1279 let dir = tempfile::tempdir().unwrap();
1280 let ctx = tpl_ctx(true);
1281
1282 call_tool(
1284 &ctx,
1285 "register_template",
1286 &json!({ "config": body(dir.path()), "launch": true }),
1287 )
1288 .await;
1289 let out = call_tool(
1290 &ctx,
1291 "register_template",
1292 &json!({ "config": body(dir.path()), "tags": ["dev"] }),
1293 )
1294 .await;
1295 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1296
1297 let out = call_tool(&ctx, "get_template", &json!({"id":"mcp-tpl"})).await;
1298 let text = out["content"][0]["text"].as_str().unwrap();
1299 assert!(text.contains("\"stable\": 1"), "{text}");
1300 assert!(text.contains("\"newest\": 2"), "{text}");
1301 assert!(text.contains("\"dev\": 2"), "{text}");
1302
1303 for (version, want) in [
1305 (json!("stable"), 1),
1306 (json!("dev"), 2),
1307 (json!("newest"), 2),
1308 (json!(1), 1),
1309 ] {
1310 let out = call_tool(
1311 &ctx,
1312 "run_template",
1313 &json!({"id":"mcp-tpl","params":{"tag":"c"},"version":version,"dry_run":true}),
1314 )
1315 .await;
1316 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1317 let text = out["content"][0]["text"].as_str().unwrap();
1318 assert!(
1319 text.contains(&format!("\"template_version\": {want}")),
1320 "{version} should resolve to v{want}: {text}"
1321 );
1322 }
1323
1324 for args in [
1326 json!({ "config": body(dir.path()), "tags": ["prd"] }),
1327 json!({ "config": body(dir.path()), "tags": ["stable"] }),
1328 ] {
1329 let out = call_tool(&ctx, "register_template", &args).await;
1330 assert_eq!(out["isError"], true, "{args}");
1331 }
1332 for bad in ["nope", "latest", "canary"] {
1333 let out = call_tool(
1334 &ctx,
1335 "run_template",
1336 &json!({"id":"mcp-tpl","params":{"tag":"c"},"version":bad}),
1337 )
1338 .await;
1339 assert_eq!(out["isError"], true, "version={bad} must be refused");
1340 }
1341 }
1342
1343 #[tokio::test]
1344 async fn register_rejects_an_invalid_config() {
1345 let out = call_tool(
1346 &tpl_ctx(true),
1347 "register_template",
1348 &json!({ "config": "version: 1\nname: x\nbogus: 1\npipeline: {}\n" }),
1349 )
1350 .await;
1351 assert_eq!(out["isError"], true);
1352 }
1353
1354 #[tokio::test]
1355 async fn env_overrides_flow_through_run_template() {
1356 let dir = tempfile::tempdir().unwrap();
1357 let ctx = tpl_ctx(true);
1358 let csv = dir.path().join("in.csv");
1359 std::fs::write(&csv, "id\n1\n").unwrap();
1360 let cfg = format!(
1361 "version: 1\nname: mcp-env\npipeline:\n source:\n type: csv\n config:\n path: {}\n sink:\n type: jsonl\n config:\n path: {}/out-${{env:MCP_TPL_SUFFIX}}.jsonl\n",
1362 csv.display(),
1363 dir.path().display()
1364 );
1365 call_tool(
1366 &ctx,
1367 "register_template",
1368 &json!({ "config": cfg, "launch": true }),
1369 )
1370 .await;
1371 let out = call_tool(
1372 &ctx,
1373 "run_template",
1374 &json!({"id":"mcp-env","env":{"MCP_TPL_SUFFIX":"eu"}}),
1375 )
1376 .await;
1377 assert_eq!(out["isError"], false, "{}", out["content"][0]["text"]);
1378 assert!(dir.path().join("out-eu.jsonl").exists());
1379 }
1380 }
1381}