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};
12
13/// Hard cap on `preview` rows regardless of the requested limit — an MCP
14/// client must never trigger a full extract of a large source.
15const PREVIEW_MAX: usize = 100;
16
17/// Build the advertised tool list for `tools/list`, honoring the mutation gate.
18pub 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    // `validate_config` and `preview` act on a caller-supplied config: they
57    // resolve `${env:}`/`${file:}`/`${secret:}` server-side and (for `preview`)
58    // build the described connector and return its records. That is a strictly
59    // higher capability than schema introspection, so it is separately gated
60    // (#456 C4) and not advertised when the caller may not use it.
61    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
189/// Dispatch a `tools/call`. Returns the MCP `tools/call` result envelope
190/// (`content` + `isError`). A tool-level failure is `tool_error(..)`, not a
191/// JSON-RPC protocol error.
192pub 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        // Gated at call time as well as in the advertised list: an agent can
198        // always name a tool it was never offered.
199        "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        // Redact any resolved secret material that reached an error string.
269        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
350/// Parse an inline config document, mirroring the file-load pipeline: resolve
351/// `${env:}` / `${file:}` / `${secret:}` per scalar, bind `${param.*}`, then take
352/// the typed path. YAML is a JSON superset, so one parser handles both wire
353/// formats.
354///
355/// `mode` decides what an unsupplied `required` param means: `Placeholder` for
356/// the read-only introspection tools (a parameterized config still validates),
357/// `Strict` where the config is about to actually run.
358fn 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
365/// Read-only introspection: a config whose required params arrive later still
366/// validates, against type-shaped placeholders.
367fn 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// ── Pipeline template tools (#444) ──────────────────────────────────────────
504
505/// Shared refusal text for a mutating template tool on a read-only server.
506#[cfg(feature = "templates")]
507const MUTATION_GATE: &str =
508    "this tool is disabled; start the MCP server with --allow-mutations to enable mutating tools";
509
510/// Refusal text for the config-executing tools when the caller lacks the scope.
511/// Phrased for the HTTP transport, which is the only place the gate closes.
512const 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/// The wired template registry, or a tool error explaining how to wire one.
518#[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/// Read the optional `version` argument: a number, or one of the closed set of
541/// named channels. Absent = `stable` (the *launched* version), so an agent that
542/// never mentions versions rides releases rather than picking up every new
543/// registration.
544#[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/// Resolve the `version` argument against the registry (every channel, derived or
554/// assigned, needs a lookup — nothing falls back to "the newest build").
555#[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/// Read the optional `tags` array, validating each against the closed channel set.
592#[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            // MCP always hands over an inline document; YAML parses JSON too.
618            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        // The MCP tool runs the pipeline in this process; nothing is persisted.
716        crate::templates::Materialize::Local,
717    )
718    .await
719    .map_err(|e| e.to_string())?;
720
721    if dry_run {
722        // Validate the materialized config without touching a sink, and never
723        // echo the body — a secret param value would be in it.
724        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    // The materialized body is JSON, which `run_from_yaml_str` parses (YAML is a
738    // JSON superset) and takes through the ordinary run path.
739    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    // ── handler coverage: scaffold / validate / preview / run ────────────────
834
835    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        // Read-only introspection binds required params to placeholders, so a
1016        // template-shaped config still validates (#444).
1017        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    // ── Pipeline template tools (#444) ──────────────────────────────────────
1028
1029    #[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            // Calling one anyway is a clear tool error, not a panic.
1063            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            // `launch: true` registers and goes live in one step.
1109            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            // A missing required param is a tool error naming it.
1138            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            // dry_run materializes + validates without writing.
1143            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            // The real run writes through the ordinary pipeline path.
1159            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            // No `launch` — the work-in-progress state.
1186            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            // An explicit build still runs, so a draft is testable.
1204            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; // v1 live
1223            call_tool(
1224                &ctx,
1225                "register_template",
1226                &json!({ "config": body(dir.path()) }),
1227            )
1228            .await; // v2 build
1229
1230            // Launch defaults to `newest`.
1231            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            // Rollback returns to v1.
1238            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            // Deprecate, then revive.
1248            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            // Launching into a retired template is refused.
1261            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            // v1 live; v2 tagged `dev` but not launched.
1283            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            // Each selector resolves to its own version.
1304            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            // A channel outside the closed set is a tool error, on both paths.
1325            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}