Skip to main content

tuff_core/
manifest.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, TuffError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum CapabilityType {
10    Skill,
11    Tool,
12    Hook,
13    Policy,
14    /// An external MCP server Tuff wires into each harness's native MCP
15    /// config. Distinct from a `tool` with `implementation.mcp = true`,
16    /// whose server code Tuff ships itself.
17    #[serde(rename = "mcp-server")]
18    McpServer,
19}
20
21impl CapabilityType {
22    pub fn plural_dir(&self) -> &'static str {
23        match self {
24            Self::Skill => "skills",
25            Self::Tool => "tools",
26            Self::Hook => "hooks",
27            Self::Policy => "policies",
28            Self::McpServer => "mcp-servers",
29        }
30    }
31
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Self::Skill => "skill",
35            Self::Tool => "tool",
36            Self::Hook => "hook",
37            Self::Policy => "policy",
38            Self::McpServer => "mcp-server",
39        }
40    }
41
42    pub fn parse(s: &str) -> Option<Self> {
43        match s {
44            "skill" => Some(Self::Skill),
45            "tool" => Some(Self::Tool),
46            "hook" => Some(Self::Hook),
47            "policy" => Some(Self::Policy),
48            "mcp-server" | "mcp" => Some(Self::McpServer),
49            _ => None,
50        }
51    }
52}
53
54impl std::fmt::Display for CapabilityType {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CapabilityManifest {
62    pub id: String,
63    pub version: String,
64    #[serde(rename = "type")]
65    pub capability_type: CapabilityType,
66    pub description: String,
67    #[serde(default)]
68    pub files: Vec<String>,
69    #[serde(default)]
70    pub parameters: Option<serde_json::Value>,
71    #[serde(default)]
72    pub implementation: Option<ImplementationConfig>,
73    #[serde(default)]
74    pub hook: Option<HookConfig>,
75    #[serde(default)]
76    pub server: Option<McpServerConfig>,
77    /// The rules of a `type = "policy"` capability.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub policy: Option<crate::policy::PolicyConfig>,
80    #[serde(default)]
81    #[allow(dead_code)]
82    pub targets: Vec<String>,
83
84    #[serde(skip)]
85    pub root: PathBuf,
86}
87
88/// Declaration of an external MCP server (`type = "mcp-server"`).
89///
90/// Secrets never appear here: every `[server.env]` value must be an
91/// [`EnvRef`], and every `[server.headers]` value a [`HeaderRef`], naming
92/// the variable to read on the developer's machine, so a manifest can be
93/// committed and shared without leaking anything.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95// The `Option` fields are skipped when absent. TOML has no null and its
96// serializer drops them anyway; JSON has one, and the lockfile is JSON, so
97// without the skip an absent `url` would be written as `"url": null`.
98pub struct McpServerConfig {
99    #[serde(default)]
100    pub transport: McpTransport,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub command: Option<String>,
103    #[serde(default)]
104    pub args: Vec<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub url: Option<String>,
107    #[serde(default)]
108    pub env: std::collections::BTreeMap<String, EnvRef>,
109    /// HTTP request headers, keyed by header name. Skipped when empty so a
110    /// server that declares none serializes byte-for-byte as it did before
111    /// headers existed, and no installed record drifts on upgrade.
112    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
113    pub headers: std::collections::BTreeMap<String, HeaderRef>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub metadata: Option<McpServerMetadata>,
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum McpTransport {
121    #[default]
122    Stdio,
123    Http,
124}
125
126impl McpTransport {
127    pub fn as_str(&self) -> &'static str {
128        match self {
129            Self::Stdio => "stdio",
130            Self::Http => "http",
131        }
132    }
133}
134
135/// A reference to an environment variable on the machine running the
136/// harness. Deliberately the only shape an env value can take — a bare
137/// string literal is rejected at parse time.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct EnvRef {
141    pub from_env: String,
142}
143
144/// A reference to the environment variable holding one HTTP header's value.
145///
146/// Headers carry secrets at least as often as environment variables do, so
147/// they take the same reference-only shape: a literal string is rejected at
148/// parse time. `format` wraps the value, with `{}` standing for it, which
149/// is what `Authorization = "Bearer <token>"` needs; it defaults to the
150/// bare value.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct HeaderRef {
154    pub from_env: String,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub format: Option<String>,
157}
158
159impl HeaderRef {
160    /// The header value to emit, given how this harness spells a reference
161    /// to the variable. The reference is substituted into `format`, so the
162    /// harness expands the variable and Tuff never sees the secret.
163    pub fn render(&self, value_reference: &str) -> String {
164        match &self.format {
165            Some(format) => format.replacen(FORMAT_PLACEHOLDER, value_reference, 1),
166            None => value_reference.to_string(),
167        }
168    }
169}
170
171/// The one substitution `format` understands.
172pub const FORMAT_PLACEHOLDER: &str = "{}";
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct McpServerMetadata {
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub tools_summary: Option<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ImplementationConfig {
182    pub language: String,
183    pub entrypoint: String,
184    #[serde(default)]
185    pub mcp: bool,
186    #[serde(default)]
187    pub runtime_deps: Vec<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct HookConfig {
192    pub event: String,
193    pub command: String,
194    #[serde(default = "default_cwd")]
195    pub working_directory: String,
196}
197
198fn default_cwd() -> String {
199    ".".to_string()
200}
201
202impl CapabilityManifest {
203    /// The files this capability installs, each confirmed to be a regular
204    /// file inside the capability directory.
205    ///
206    /// Every entry in `files`, and a tool's entrypoint, is a path the
207    /// manifest's author chose, and a capability can come from anyone's
208    /// repository. Each is copied into the project under the harness
209    /// directory using the same relative path, so an entry that climbs out
210    /// with `..`, is absolute, or passes through a symbolic link would read
211    /// a file from outside the capability and write it outside the place
212    /// Tuff installs to. Those are refused, the same way pack members and
213    /// skill directories already refuse them.
214    pub fn source_files(&self) -> Result<Vec<PathBuf>> {
215        let mut paths = Vec::new();
216
217        for f in &self.files {
218            let path = contained_source_file(&self.root, f)?;
219            paths.push(path);
220        }
221
222        if self.capability_type == CapabilityType::Tool
223            && let Some(ref imp) = self.implementation
224        {
225            let ep_path = self.root.join(imp.entrypoint.trim_start_matches("./"));
226            if !paths.contains(&ep_path) && ep_path.exists() {
227                paths.push(contained_source_file(&self.root, &imp.entrypoint)?);
228            }
229        }
230
231        Ok(paths)
232    }
233
234    /// Each listed file with the path it installs under: its path in the
235    /// capability directory with one leading `src/` removed.
236    ///
237    /// The same file listed twice installs once. Two different files that
238    /// would install under the same path are refused: `check.sh` and
239    /// `src/check.sh` both install as `check.sh`, and writing both used to
240    /// keep whichever came last without saying so.
241    pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
242        let mut installed: Vec<(String, PathBuf, Vec<u8>)> = Vec::new();
243        for p in self.source_files()? {
244            let rel = p
245                .strip_prefix(&self.root)
246                .unwrap_or(&p)
247                .to_string_lossy()
248                .replace('\\', "/");
249            let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
250            if let Some((_, first, _)) = installed.iter().find(|(name, _, _)| *name == rel) {
251                if *first == p {
252                    continue;
253                }
254                return Err(TuffError::refused(format!(
255                    "capability '{}' lists two files that would install as '{rel}': {} and {}",
256                    self.id,
257                    first.display(),
258                    p.display()
259                ))
260                .with_hint("rename one of them, or list only one"));
261            }
262            let content = std::fs::read(&p)?;
263            installed.push((rel, p, content));
264        }
265        Ok(installed
266            .into_iter()
267            .map(|(rel, _, content)| (rel, content))
268            .collect())
269    }
270}
271
272/// Resolve one manifest-listed path to a regular file inside `root`.
273///
274/// The entry must be relative, may start with `./`, and may not contain
275/// `..`, a root, or a platform prefix. No component along it may be a
276/// symbolic link, since a link is the other way to reach outside the
277/// directory while every component still looks plain.
278fn contained_source_file(root: &Path, entry: &str) -> Result<PathBuf> {
279    let trimmed = entry.trim_start_matches("./");
280    let relative = crate::pack::validate_relative_path(Path::new(trimmed)).map_err(|_| {
281        TuffError::refused(format!(
282            "capability source path must stay inside the capability directory: '{entry}'"
283        ))
284        .with_hint("list files by their path relative to tuff.toml, without '..' or a leading '/'")
285    })?;
286    let mut current = root.to_path_buf();
287    for component in relative.components() {
288        current.push(component);
289        match std::fs::symlink_metadata(&current) {
290            Ok(metadata) if metadata.file_type().is_symlink() => {
291                return Err(TuffError::refused(format!(
292                    "symbolic links are not allowed in capability sources: {}",
293                    current.display()
294                )));
295            }
296            Ok(_) => {}
297            Err(_) => {
298                return Err(TuffError::not_found(format!(
299                    "capability source file not found: {}",
300                    root.join(&relative).display()
301                )));
302            }
303        }
304    }
305    if !current.is_file() {
306        return Err(TuffError::usage(format!(
307            "capability source must be a file, not a directory: {}",
308            current.display()
309        )));
310    }
311    Ok(current)
312}
313
314/// Refuse a capability id that could name a directory outside where it
315/// belongs.
316///
317/// The id names the directory a capability is installed into and, when it
318/// is deleted, the directory Tuff removes: `<harness>/<kind>s/<id>`. Ids may
319/// be nested, `security/security-review` installs into a grouping directory
320/// and that is a supported layout, so the rule is not "no slashes". It is
321/// that every segment is a plain name: no `..` or `.`, no empty segment from
322/// a leading, trailing, or doubled `/`, no backslash, and no NUL. An id that
323/// breaks it would aim install and delete outside that directory, and a
324/// manifest from someone else's repository, or a lockfile committed to one,
325/// could then make `tuff delete` remove a directory outside the project.
326/// Every id Tuff accepts goes through here: manifest ids, name overrides,
327/// pack artifact members, and every name read back from a lockfile.
328pub fn validate_capability_id(id: &str) -> Result<()> {
329    let plain = !id.is_empty()
330        && id.trim() == id
331        && !id.contains(['\\', '\0'])
332        && id
333            .split('/')
334            .all(|segment| !segment.is_empty() && segment != "." && segment != "..");
335    if plain {
336        return Ok(());
337    }
338    Err(TuffError::refused(format!(
339        "capability id must be a relative path of plain names: '{}'",
340        id.escape_debug()
341    ))
342    .with_hint(
343        "use a name such as 'release-checklist' or 'security/review', without '..', '.', a leading '/', or '\\'",
344    ))
345}
346
347fn validate_non_empty(field: &str, value: &str) -> Result<()> {
348    if value.is_empty() {
349        return Err(TuffError::usage(format!(
350            "capability manifest field '{field}' must be a non-empty string"
351        )));
352    }
353    Ok(())
354}
355
356pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
357    let manifest_path = capability_dir.join("tuff.toml");
358    if !manifest_path.exists() {
359        return Err(TuffError::not_found(format!(
360            "capability manifest not found: {}",
361            manifest_path.display()
362        )));
363    }
364
365    let raw = std::fs::read_to_string(&manifest_path)?;
366    let mut manifest = parse_manifest(&raw, &manifest_path)?;
367    manifest.root = capability_dir.to_path_buf();
368
369    validate_non_empty("id", &manifest.id)?;
370    validate_capability_id(&manifest.id)?;
371    validate_non_empty("version", &manifest.version)?;
372    validate_non_empty("type", &manifest.capability_type.to_string())?;
373    validate_non_empty("description", &manifest.description)?;
374
375    match manifest.capability_type {
376        CapabilityType::Skill => {
377            if manifest.files.is_empty() {
378                return Err(TuffError::usage(
379                    "skill capability 'files' must not be empty",
380                ));
381            }
382            manifest.source_files()?;
383        }
384        CapabilityType::Tool => {
385            if manifest.parameters.is_none() {
386                return Err(TuffError::usage(
387                    "tool capability requires a [parameters] section with JSON Schema",
388                ));
389            }
390            if manifest.implementation.is_none() {
391                return Err(TuffError::usage(
392                    "tool capability requires an [implementation] section",
393                ));
394            }
395
396            let params = manifest.parameters.as_ref().unwrap();
397            crate::tool::validate_json_schema(params)?;
398
399            let impl_cfg = manifest.implementation.as_ref().unwrap();
400            crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
401
402            if !impl_cfg.runtime_deps.is_empty() {
403                eprintln!(
404                    "note: this tool requires runtime dependencies: {}",
405                    impl_cfg.runtime_deps.join(", ")
406                );
407            }
408
409            if !manifest.files.is_empty() {
410                manifest.source_files()?;
411            }
412        }
413        CapabilityType::Hook => {
414            let hook_cfg = manifest
415                .hook
416                .as_ref()
417                .ok_or_else(|| TuffError::usage("hook capability requires a [hook] section"))?;
418
419            if hook_cfg.event.trim().is_empty() {
420                return Err(TuffError::usage("hook 'event' must be a non-empty string"));
421            }
422            if hook_cfg.command.trim().is_empty() {
423                return Err(TuffError::usage(
424                    "hook 'command' must be a non-empty string",
425                ));
426            }
427
428            crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
429
430            eprintln!(
431                "note: this hook runs '{}' on event '{}' — it will not be executed during install",
432                hook_cfg.command, hook_cfg.event
433            );
434
435            if !manifest.files.is_empty() {
436                manifest.source_files()?;
437            }
438        }
439        CapabilityType::Policy => {
440            let policy = manifest.policy.as_ref().ok_or_else(|| {
441                TuffError::usage(
442                    "policy capability requires a [policy] section with at least one [[policy.rules]] entry",
443                )
444            })?;
445            crate::policy::validate_policy(policy)?;
446            if !manifest.files.is_empty() {
447                return Err(TuffError::usage(
448                    "a policy capability installs no files; remove `files` from its tuff.toml",
449                ));
450            }
451        }
452        CapabilityType::McpServer => {
453            let server = manifest.server.as_ref().ok_or_else(|| {
454                TuffError::usage("mcp-server capability requires a [server] section")
455            })?;
456            validate_mcp_server(server)?;
457
458            if !manifest.files.is_empty() {
459                manifest.source_files()?;
460            }
461        }
462    }
463
464    Ok(manifest)
465}
466
467pub fn validate_mcp_server(server: &McpServerConfig) -> Result<()> {
468    match server.transport {
469        McpTransport::Stdio => {
470            if server
471                .command
472                .as_deref()
473                .is_none_or(|c| c.trim().is_empty())
474            {
475                return Err(TuffError::usage(
476                    "mcp-server with transport = \"stdio\" requires a non-empty 'command'",
477                ));
478            }
479        }
480        McpTransport::Http => {
481            if server.url.as_deref().is_none_or(|u| u.trim().is_empty()) {
482                return Err(TuffError::usage(
483                    "mcp-server with transport = \"http\" requires a non-empty 'url'",
484                ));
485            }
486        }
487    }
488    for (name, reference) in &server.env {
489        if name.trim().is_empty() {
490            return Err(TuffError::usage("[server.env] keys must be non-empty"));
491        }
492        if reference.from_env.trim().is_empty() {
493            return Err(TuffError::usage(format!(
494                "[server.env] {name} must reference a variable: {name} = {{ from_env = \"VAR\" }}"
495            )));
496        }
497    }
498    validate_mcp_headers(server)?;
499    Ok(())
500}
501
502/// Headers belong to the request a harness makes, so they are meaningful
503/// only over HTTP; on a stdio server they would be silently dropped, which
504/// is exactly the quiet-success failure RFC-106 exists to remove.
505fn validate_mcp_headers(server: &McpServerConfig) -> Result<()> {
506    if !server.headers.is_empty() && server.transport != McpTransport::Http {
507        return Err(TuffError::usage(
508            "[server.headers] applies to transport = \"http\"; a stdio server passes \
509             secrets through [server.env]",
510        ));
511    }
512    for (name, reference) in &server.headers {
513        if name.trim().is_empty() {
514            return Err(TuffError::usage("[server.headers] keys must be non-empty"));
515        }
516        if reference.from_env.trim().is_empty() {
517            return Err(TuffError::usage(format!(
518                "[server.headers] {name} must reference a variable: \
519                 {name} = {{ from_env = \"VAR\" }}"
520            )));
521        }
522        if let Some(format) = &reference.format {
523            let placeholders = format.matches(FORMAT_PLACEHOLDER).count();
524            if placeholders != 1 {
525                let problem = if placeholders == 0 {
526                    "would discard the value"
527                } else {
528                    "would repeat the value"
529                };
530                return Err(TuffError::usage(format!(
531                    "[server.headers] {name}: format \"{format}\" {problem}"
532                ))
533                .with_hint("format must contain exactly one {} placeholder, as in \"Bearer {}\""));
534            }
535        }
536    }
537    Ok(())
538}
539
540/// Parse a manifest, turning serde's opaque "invalid type: string" failure
541/// for a literal `[server.env]` value into an error that says what to write
542/// instead.
543fn parse_manifest(raw: &str, manifest_path: &Path) -> Result<CapabilityManifest> {
544    toml::from_str(raw).map_err(|error: toml::de::Error| {
545        let message = error.to_string();
546        let looks_literal =
547            message.contains("invalid type: string") || message.contains("expected a table");
548        let literal_table = looks_literal
549            .then(|| {
550                ["[server.env]", "[server.headers]"]
551                    .into_iter()
552                    .find(|table| raw.contains(table))
553            })
554            .flatten();
555        if let Some(table) = literal_table {
556            let example = if table == "[server.headers]" {
557                "Authorization = { from_env = \"TOKEN\", format = \"Bearer {}\" }"
558            } else {
559                "NAME = { from_env = \"NAME\" }"
560            };
561            TuffError::usage(format!(
562                "invalid manifest at {}: {} values must be references, never \
563                 literals — write {} ({})",
564                manifest_path.display(),
565                table,
566                example,
567                message.trim()
568            ))
569        } else if message.contains("unknown variant `workflow`") {
570            TuffError::usage(format!(
571                "invalid manifest at {}: Tuff no longer has workflow capabilities",
572                manifest_path.display()
573            ))
574            .with_hint("install the skills, tools, and hooks the workflow listed on their own, or group them in a pack")
575        } else {
576            TuffError::from(error)
577        }
578    })
579}
580
581/// Writes a capability manifest as deterministic TOML.
582///
583/// # Errors
584///
585/// Returns an error when serialization or filesystem writing fails.
586pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
587    std::fs::write(path, toml::to_string_pretty(manifest)?)?;
588    Ok(())
589}
590
591/// The version a capability source declares for itself, if any: `version`
592/// in `tuff.toml`, else `version:` or `metadata.version:` in the `SKILL.md`
593/// frontmatter (RFC-101 tier 2). It is what the author wrote, not what was
594/// released: it may not change when the content does, which is why a
595/// release tag outranks it and the lockfile records which one it holds.
596pub fn declared_version(dir: &Path) -> Option<String> {
597    if dir.join("tuff.toml").is_file() {
598        return load_manifest(dir).ok().map(|manifest| manifest.version);
599    }
600    let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
601    frontmatter_version(&skill)
602}
603
604/// Read a version out of `SKILL.md` frontmatter without a YAML parser: a
605/// top-level `version:` line, else `version:` indented under `metadata:`,
606/// which is where the Agent Skills specification puts it. Quotes are
607/// stripped; anything else is taken as written.
608pub fn frontmatter_version(skill: &str) -> Option<String> {
609    let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
610    if lines.next()?.trim() != "---" {
611        return None;
612    }
613    let mut in_metadata = false;
614    let mut nested = None;
615    for line in lines {
616        if line.trim() == "---" {
617            break;
618        }
619        let indented = line.starts_with([' ', '\t']);
620        if !indented {
621            in_metadata = line.trim_end() == "metadata:";
622            if let Some(value) = line.strip_prefix("version:") {
623                return frontmatter_scalar(value);
624            }
625            continue;
626        }
627        if in_metadata
628            && nested.is_none()
629            && let Some(value) = line.trim_start().strip_prefix("version:")
630        {
631            nested = frontmatter_scalar(value);
632        }
633    }
634    nested
635}
636
637/// The description a capability source declares for itself, if any:
638/// `description` in `tuff.toml`, else `description:` in the `SKILL.md`
639/// frontmatter, which is where the Agent Skills specification puts it.
640///
641/// Unlike a version, a description is prose that no command depends on, so
642/// an absent one is an empty line in a report rather than an error.
643pub fn declared_description(dir: &Path) -> Option<String> {
644    if dir.join("tuff.toml").is_file() {
645        return load_manifest(dir).ok().map(|manifest| manifest.description);
646    }
647    let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
648    frontmatter_description(&skill)
649}
650
651/// Read a top-level `description:` out of `SKILL.md` frontmatter.
652///
653/// Only the top level, and only a single line: a folded or block scalar is
654/// left alone rather than half-read, because a description this misses is a
655/// blank cell, while one it mangles is a wrong cell.
656pub fn frontmatter_description(skill: &str) -> Option<String> {
657    let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
658    if lines.next()?.trim() != "---" {
659        return None;
660    }
661    for line in lines {
662        if line.trim() == "---" {
663            break;
664        }
665        if line.starts_with([' ', '\t']) {
666            continue;
667        }
668        if let Some(value) = line.strip_prefix("description:") {
669            return frontmatter_text(value);
670        }
671    }
672    None
673}
674
675/// A frontmatter value that is allowed to contain spaces, unlike a version.
676fn frontmatter_text(value: &str) -> Option<String> {
677    let value = value.trim();
678    let value = value
679        .strip_prefix('"')
680        .and_then(|rest| rest.strip_suffix('"'))
681        .or_else(|| {
682            value
683                .strip_prefix('\'')
684                .and_then(|rest| rest.strip_suffix('\''))
685        })
686        .unwrap_or(value)
687        .trim();
688    // `>` and `|` open a multi-line scalar whose body is on the next lines.
689    if value.is_empty() || value.starts_with(['>', '|']) {
690        return None;
691    }
692    Some(value.to_string())
693}
694
695fn frontmatter_scalar(value: &str) -> Option<String> {
696    let value = value.trim();
697    let value = value
698        .strip_prefix('"')
699        .and_then(|rest| rest.strip_suffix('"'))
700        .or_else(|| {
701            value
702                .strip_prefix('\'')
703                .and_then(|rest| rest.strip_suffix('\''))
704        })
705        .unwrap_or(value)
706        .trim();
707    (!value.is_empty() && !value.contains(char::is_whitespace)).then(|| value.to_string())
708}
709
710pub fn synthetic_manifest(
711    skill_dir: &Path,
712    name: &str,
713    version: &str,
714) -> Result<CapabilityManifest> {
715    validate_capability_id(name)?;
716    let skill_file = skill_dir.join("SKILL.md");
717    if !skill_file.exists() {
718        return Err(TuffError::not_found(format!(
719            "skill entrypoint not found: {}",
720            skill_file.display()
721        )));
722    }
723    let mut files = Vec::new();
724    walk_skill_dir(skill_dir, "", &mut files)?;
725    files.sort();
726
727    Ok(CapabilityManifest {
728        id: name.to_string(),
729        version: version.to_string(),
730        capability_type: CapabilityType::Skill,
731        description: "Installed from git source.".to_string(),
732        files,
733        parameters: None,
734        implementation: None,
735        hook: None,
736        server: None,
737        policy: None,
738        targets: Vec::new(),
739        root: skill_dir.to_path_buf(),
740    })
741}
742
743fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
744    for entry in std::fs::read_dir(base)? {
745        let entry = entry?;
746        let path = entry.path();
747        let metadata = std::fs::symlink_metadata(&path)?;
748        if metadata.file_type().is_symlink() {
749            return Err(TuffError::refused(format!(
750                "symbolic links are not allowed in capability sources: {}",
751                path.display()
752            )));
753        }
754        let rel = if prefix.is_empty() {
755            entry.file_name().to_string_lossy().to_string()
756        } else {
757            format!("{}/{}", prefix, entry.file_name().to_string_lossy())
758        };
759        if metadata.is_dir() {
760            walk_skill_dir(&path, &rel, files)?;
761        } else if metadata.is_file() && rel != "tuff.toml" {
762            files.push(rel);
763        }
764    }
765    Ok(())
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use std::fs;
772    use tempfile::TempDir;
773
774    fn write_manifest(dir: &std::path::Path, content: &str) {
775        fs::write(dir.join("tuff.toml"), content).unwrap();
776    }
777
778    #[test]
779    fn frontmatter_version_reads_top_level_then_metadata() {
780        assert_eq!(
781            frontmatter_version("---\nname: x\nversion: 1.2.0\n---\n# X\n").as_deref(),
782            Some("1.2.0")
783        );
784        assert_eq!(
785            frontmatter_version("---\nname: x\nversion: \"1.2.0\"\n---\n").as_deref(),
786            Some("1.2.0")
787        );
788        // The Agent Skills specification nests it under `metadata`.
789        assert_eq!(
790            frontmatter_version(
791                "---\nname: x\nmetadata:\n  author: org\n  version: \"1.0\"\n---\n"
792            )
793            .as_deref(),
794            Some("1.0")
795        );
796        // Top level wins over nested when both are present.
797        assert_eq!(
798            frontmatter_version("---\nmetadata:\n  version: 0.9.0\nversion: 1.2.0\n---\n")
799                .as_deref(),
800            Some("1.2.0")
801        );
802        // A `version:` nested under some other key is not the skill's.
803        assert_eq!(
804            frontmatter_version("---\nname: x\nextra:\n  version: 3.0.0\n---\n"),
805            None
806        );
807        assert_eq!(
808            frontmatter_version("# no frontmatter\nversion: 1.0.0\n"),
809            None
810        );
811        assert_eq!(
812            frontmatter_version("---\nname: x\n---\nversion: 9.9.9\n"),
813            None
814        );
815        assert_eq!(frontmatter_version("---\nversion:\n---\n"), None);
816        assert_eq!(frontmatter_version("---\nversion: 1.2.0 beta\n---\n"), None);
817        assert_eq!(
818            frontmatter_version("---\r\nname: x\r\nversion: 2.0.0\r\n---\r\n").as_deref(),
819            Some("2.0.0")
820        );
821    }
822
823    #[test]
824    fn declared_description_reads_the_frontmatter_and_prefers_the_manifest() {
825        let tmp = TempDir::new().unwrap();
826        fs::write(
827            tmp.path().join("SKILL.md"),
828            "---\nname: x\ndescription: Reviews a diff for security problems.\n---\n# X\n",
829        )
830        .unwrap();
831        assert_eq!(
832            declared_description(tmp.path()).as_deref(),
833            Some("Reviews a diff for security problems.")
834        );
835
836        write_manifest(
837            tmp.path(),
838            r#"id = "x"
839version = "1.0.0"
840type = "skill"
841description = "From the manifest"
842files = ["SKILL.md"]
843"#,
844        );
845        assert_eq!(
846            declared_description(tmp.path()).as_deref(),
847            Some("From the manifest")
848        );
849    }
850
851    #[test]
852    fn a_multi_line_description_is_left_alone_rather_than_half_read() {
853        // A folded scalar's text is on the following lines, so reading the
854        // marker would record ">" as the description.
855        assert_eq!(
856            frontmatter_description("---\nname: x\ndescription: >\n  Long text here.\n---\n"),
857            None
858        );
859        // An indented `description:` belongs to some nested mapping, not to
860        // the skill.
861        assert_eq!(
862            frontmatter_description("---\nmetadata:\n  description: Nested.\n---\n"),
863            None
864        );
865        // No frontmatter at all is not an error.
866        assert_eq!(frontmatter_description("# Just a heading\n"), None);
867    }
868
869    #[test]
870    fn declared_version_prefers_the_manifest_over_the_frontmatter() {
871        let tmp = TempDir::new().unwrap();
872        fs::write(
873            tmp.path().join("SKILL.md"),
874            "---\nname: x\nversion: 2.0.0\n---\n# X\n",
875        )
876        .unwrap();
877        assert_eq!(declared_version(tmp.path()).as_deref(), Some("2.0.0"));
878        fs::write(
879            tmp.path().join("tuff.toml"),
880            "id = \"x\"\nversion = \"1.0.0\"\ntype = \"skill\"\ndescription = \"d\"\nfiles = [\"SKILL.md\"]\n",
881        )
882        .unwrap();
883        assert_eq!(declared_version(tmp.path()).as_deref(), Some("1.0.0"));
884
885        let bare = TempDir::new().unwrap();
886        fs::write(bare.path().join("SKILL.md"), "# no version\n").unwrap();
887        assert_eq!(declared_version(bare.path()), None);
888    }
889
890    #[test]
891    fn load_skill_manifest_succeeds() {
892        let tmp = TempDir::new().unwrap();
893        fs::create_dir_all(tmp.path().join("src")).unwrap();
894        fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
895        write_manifest(
896            tmp.path(),
897            r#"id = "test"
898version = "1.0.0"
899type = "skill"
900description = "A test skill"
901files = ["src/SKILL.md"]
902"#,
903        );
904        let m = load_manifest(tmp.path()).unwrap();
905        assert_eq!(m.id, "test");
906        assert_eq!(m.capability_type, CapabilityType::Skill);
907    }
908
909    #[test]
910    fn load_tool_manifest_succeeds() {
911        let tmp = TempDir::new().unwrap();
912        fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
913        write_manifest(
914            tmp.path(),
915            r#"id = "tool1"
916version = "1.0.0"
917type = "tool"
918description = "A test tool"
919files = ["run.sh"]
920
921[parameters]
922type = "object"
923required = ["x"]
924[parameters.properties.x]
925type = "string"
926description = "x"
927
928[implementation]
929language = "bash"
930entrypoint = "run.sh"
931"#,
932        );
933        let m = load_manifest(tmp.path()).unwrap();
934        assert_eq!(m.capability_type, CapabilityType::Tool);
935        assert!(m.implementation.is_some());
936    }
937
938    #[test]
939    fn load_hook_manifest_succeeds() {
940        let tmp = TempDir::new().unwrap();
941        write_manifest(
942            tmp.path(),
943            r#"id = "hook1"
944version = "1.0.0"
945type = "hook"
946description = "A test hook"
947
948[hook]
949event = "before_finish"
950command = "cargo test"
951"#,
952        );
953        let m = load_manifest(tmp.path()).unwrap();
954        assert_eq!(m.capability_type, CapabilityType::Hook);
955        assert!(m.hook.is_some());
956    }
957
958    #[test]
959    fn load_rejects_unsupported_type() {
960        let tmp = TempDir::new().unwrap();
961        write_manifest(
962            tmp.path(),
963            r#"id = "bad"
964version = "1.0.0"
965type = "unknown"
966description = "Bad"
967files = ["SKILL.md"]
968"#,
969        );
970        assert!(load_manifest(tmp.path()).is_err());
971    }
972
973    #[test]
974    fn load_rejects_missing_manifest() {
975        let tmp = TempDir::new().unwrap();
976        assert!(load_manifest(tmp.path()).is_err());
977    }
978
979    #[test]
980    fn source_files_resolves_paths() {
981        let tmp = TempDir::new().unwrap();
982        fs::create_dir_all(tmp.path().join("src")).unwrap();
983        fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
984        let m = CapabilityManifest {
985            id: "t".into(),
986            version: "1.0".into(),
987            capability_type: CapabilityType::Skill,
988            description: "desc".into(),
989            files: vec!["src/SKILL.md".into()],
990            parameters: None,
991            implementation: None,
992            hook: None,
993            server: None,
994            policy: None,
995            targets: vec![],
996            root: tmp.path().to_path_buf(),
997        };
998        let files = m.source_files().unwrap();
999        assert_eq!(files.len(), 1);
1000        assert!(files[0].ends_with("SKILL.md"));
1001    }
1002
1003    fn manifest_listing(root: &Path, files: &[&str]) -> CapabilityManifest {
1004        CapabilityManifest {
1005            id: "t".into(),
1006            version: "1.0".into(),
1007            capability_type: CapabilityType::Hook,
1008            description: "desc".into(),
1009            files: files.iter().map(|f| f.to_string()).collect(),
1010            parameters: None,
1011            implementation: None,
1012            hook: None,
1013            server: None,
1014            policy: None,
1015            targets: vec![],
1016            root: root.to_path_buf(),
1017        }
1018    }
1019
1020    #[test]
1021    fn source_files_refuse_a_path_that_climbs_out_of_the_capability() {
1022        // A capability from someone else's repository chooses these paths.
1023        // `../` would read a file beside the capability and, because the
1024        // relative path is reused for the destination, write it outside the
1025        // harness directory Tuff installs into.
1026        let tmp = TempDir::new().unwrap();
1027        let capability = tmp.path().join("capability");
1028        fs::create_dir_all(&capability).unwrap();
1029        fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
1030        fs::write(capability.join("inside.txt"), "inside").unwrap();
1031
1032        for entry in [
1033            "../outside.txt",
1034            "sub/../../outside.txt",
1035            "./../outside.txt",
1036        ] {
1037            let error = manifest_listing(&capability, &["inside.txt", entry])
1038                .source_files()
1039                .unwrap_err();
1040            assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{entry}");
1041            assert!(
1042                error
1043                    .to_string()
1044                    .contains("must stay inside the capability directory"),
1045                "{entry}: {error}"
1046            );
1047        }
1048        let absolute = tmp.path().join("outside.txt");
1049        let error = manifest_listing(&capability, &[absolute.to_str().unwrap()])
1050            .source_files()
1051            .unwrap_err();
1052        assert_eq!(error.kind(), crate::error::ErrorKind::Refused);
1053    }
1054
1055    #[cfg(unix)]
1056    #[test]
1057    fn source_files_refuse_a_symbolic_link_anywhere_along_the_path() {
1058        let tmp = TempDir::new().unwrap();
1059        let capability = tmp.path().join("capability");
1060        let secrets = tmp.path().join("secrets");
1061        fs::create_dir_all(capability.join("docs")).unwrap();
1062        fs::create_dir_all(&secrets).unwrap();
1063        fs::write(secrets.join("key.txt"), "pretend secret").unwrap();
1064        std::os::unix::fs::symlink(secrets.join("key.txt"), capability.join("notes.md")).unwrap();
1065        std::os::unix::fs::symlink(&secrets, capability.join("docs").join("linked")).unwrap();
1066
1067        for entry in ["notes.md", "docs/linked/key.txt"] {
1068            let error = manifest_listing(&capability, &[entry])
1069                .source_files()
1070                .unwrap_err();
1071            assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{entry}");
1072            assert!(
1073                error.to_string().contains("symbolic links are not allowed"),
1074                "{entry}: {error}"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn source_files_accept_plain_nested_and_dot_slash_paths() {
1081        let tmp = TempDir::new().unwrap();
1082        fs::create_dir_all(tmp.path().join("src/lib")).unwrap();
1083        fs::write(tmp.path().join("src/lib/check.sh"), "x").unwrap();
1084        fs::write(tmp.path().join("run.sh"), "x").unwrap();
1085
1086        let files = manifest_listing(tmp.path(), &["./run.sh", "src/lib/check.sh"])
1087            .source_files()
1088            .unwrap();
1089        assert_eq!(
1090            files,
1091            vec![
1092                tmp.path().join("run.sh"),
1093                tmp.path().join("src/lib/check.sh")
1094            ]
1095        );
1096        let error = manifest_listing(tmp.path(), &["src"])
1097            .source_files()
1098            .unwrap_err();
1099        assert!(error.to_string().contains("must be a file"), "{error}");
1100    }
1101
1102    #[test]
1103    fn listed_files_that_install_to_the_same_path_are_refused_but_a_repeat_is_not() {
1104        let tmp = TempDir::new().unwrap();
1105        fs::create_dir_all(tmp.path().join("src")).unwrap();
1106        fs::write(tmp.path().join("check.sh"), "top").unwrap();
1107        fs::write(tmp.path().join("src/check.sh"), "src").unwrap();
1108
1109        let repeated = manifest_listing(tmp.path(), &["check.sh", "./check.sh", "check.sh"])
1110            .read_source_contents_with_names()
1111            .unwrap();
1112        assert_eq!(repeated, vec![("check.sh".to_string(), b"top".to_vec())]);
1113
1114        let error = manifest_listing(tmp.path(), &["check.sh", "src/check.sh"])
1115            .read_source_contents_with_names()
1116            .unwrap_err();
1117        assert_eq!(error.kind(), crate::error::ErrorKind::Refused);
1118        assert!(
1119            error.to_string().contains("would install as 'check.sh'"),
1120            "{error}"
1121        );
1122    }
1123
1124    #[test]
1125    fn source_files_rejects_missing_file() {
1126        let tmp = TempDir::new().unwrap();
1127        let m = CapabilityManifest {
1128            id: "t".into(),
1129            version: "1.0".into(),
1130            capability_type: CapabilityType::Skill,
1131            description: "desc".into(),
1132            files: vec!["src/MISSING.md".into()],
1133            parameters: None,
1134            implementation: None,
1135            hook: None,
1136            server: None,
1137            policy: None,
1138            targets: vec![],
1139            root: tmp.path().to_path_buf(),
1140        };
1141        assert!(m.source_files().is_err());
1142    }
1143
1144    #[test]
1145    fn a_capability_id_is_a_relative_path_of_plain_names() {
1146        // Nested ids are a supported layout (`tuff add skill <repo>
1147        // security/security-review`), so they must keep working.
1148        for id in [
1149            "release-checklist",
1150            "tuff-cli-guide",
1151            "a.b",
1152            "x_1",
1153            "...x",
1154            "security/security-review",
1155            "a/b/c",
1156        ] {
1157            assert!(validate_capability_id(id).is_ok(), "{id}");
1158        }
1159        for id in [
1160            "",
1161            ".",
1162            "..",
1163            "../victim",
1164            "../../victim",
1165            "a/../b",
1166            "a/..",
1167            "./a",
1168            "a/./b",
1169            "/abs",
1170            "a/",
1171            "a//b",
1172            "a\\b",
1173            "nul\0x",
1174            " padded",
1175            "padded ",
1176        ] {
1177            let error = validate_capability_id(id).unwrap_err();
1178            assert_eq!(error.kind(), crate::error::ErrorKind::Refused, "{id:?}");
1179        }
1180    }
1181
1182    #[test]
1183    fn a_manifest_with_an_escaping_id_is_refused_at_load() {
1184        let tmp = TempDir::new().unwrap();
1185        fs::write(
1186            tmp.path().join("tuff.toml"),
1187            "id = \"../../victim\"\ntype = \"hook\"\nversion = \"1.0.0\"\ndescription = \"d\"\n[hook]\nevent = \"stop\"\ncommand = \"true\"\n",
1188        )
1189        .unwrap();
1190        let error = load_manifest(tmp.path()).unwrap_err();
1191        assert!(
1192            error.to_string().contains("relative path of plain names"),
1193            "{error}"
1194        );
1195    }
1196
1197    #[test]
1198    fn a_policy_manifest_loads_and_refuses_files() {
1199        let tmp = TempDir::new().unwrap();
1200        let head = "id = \"guard\"\ntype = \"policy\"\nversion = \"1.0.0\"\ndescription = \"d\"\n";
1201        let rules =
1202            "[[policy.rules]]\neffect = \"deny\"\ncommand = [\"git\", \"push\", \"--force\"]\n";
1203        fs::write(tmp.path().join("tuff.toml"), format!("{head}{rules}")).unwrap();
1204        let manifest = load_manifest(tmp.path()).unwrap();
1205        assert_eq!(manifest.policy.unwrap().rules.len(), 1);
1206
1207        fs::write(tmp.path().join("tuff.toml"), head).unwrap();
1208        let error = load_manifest(tmp.path()).unwrap_err();
1209        assert!(
1210            error.to_string().contains("requires a [policy] section"),
1211            "{error}"
1212        );
1213
1214        fs::write(tmp.path().join("x.sh"), "x").unwrap();
1215        fs::write(
1216            tmp.path().join("tuff.toml"),
1217            format!("{head}files = [\"x.sh\"]\n{rules}"),
1218        )
1219        .unwrap();
1220        let error = load_manifest(tmp.path()).unwrap_err();
1221        assert!(error.to_string().contains("installs no files"), "{error}");
1222    }
1223
1224    #[test]
1225    fn validate_non_empty_rejects_empty() {
1226        assert!(validate_non_empty("id", "").is_err());
1227        assert!(validate_non_empty("id", "ok").is_ok());
1228    }
1229
1230    fn load_mcp(toml_body: &str) -> Result<CapabilityManifest> {
1231        let tmp = TempDir::new().unwrap();
1232        fs::write(tmp.path().join("tuff.toml"), toml_body).unwrap();
1233        load_manifest(tmp.path())
1234    }
1235
1236    const MCP_HEAD: &str =
1237        "id = \"srv\"\nversion = \"1.0.0\"\ntype = \"mcp-server\"\ndescription = \"d\"\n";
1238
1239    #[test]
1240    fn mcp_server_requires_server_section() {
1241        let error = load_mcp(MCP_HEAD).unwrap_err().to_string();
1242        assert!(error.contains("requires a [server] section"), "{error}");
1243    }
1244
1245    #[test]
1246    fn mcp_server_stdio_requires_command_and_http_requires_url() {
1247        let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"stdio\"\n"))
1248            .unwrap_err()
1249            .to_string();
1250        assert!(error.contains("requires a non-empty 'command'"), "{error}");
1251        let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"http\"\n"))
1252            .unwrap_err()
1253            .to_string();
1254        assert!(error.contains("requires a non-empty 'url'"), "{error}");
1255        let ok = load_mcp(&format!(
1256            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n"
1257        ))
1258        .unwrap();
1259        assert_eq!(ok.server.unwrap().transport, McpTransport::Http);
1260    }
1261
1262    #[test]
1263    fn mcp_server_env_must_be_a_reference_not_a_literal() {
1264        let error = load_mcp(&format!(
1265            "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = \"literal\"\n"
1266        ))
1267        .unwrap_err()
1268        .to_string();
1269        assert!(error.contains("from_env"), "{error}");
1270
1271        let ok = load_mcp(&format!(
1272            "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = {{ from_env = \"MY_TOKEN\" }}\n"
1273        ))
1274        .unwrap();
1275        assert_eq!(ok.server.unwrap().env["TOKEN"].from_env, "MY_TOKEN");
1276    }
1277
1278    #[test]
1279    fn mcp_server_headers_must_be_references_not_literals() {
1280        let error = load_mcp(&format!(
1281            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1282             [server.headers]\nAuthorization = \"Bearer secret\"\n"
1283        ))
1284        .unwrap_err()
1285        .to_string();
1286        assert!(error.contains("[server.headers]"), "{error}");
1287        assert!(error.contains("from_env"), "{error}");
1288    }
1289
1290    #[test]
1291    fn mcp_server_header_reference_carries_an_optional_format() {
1292        let server = load_mcp(&format!(
1293            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1294             [server.headers]\n\
1295             Authorization = {{ from_env = \"NOTION_TOKEN\", format = \"Bearer {{}}\" }}\n\
1296             X-Api-Key = {{ from_env = \"API_KEY\" }}\n"
1297        ))
1298        .unwrap()
1299        .server
1300        .unwrap();
1301        assert_eq!(server.headers["Authorization"].from_env, "NOTION_TOKEN");
1302        assert_eq!(
1303            server.headers["Authorization"].render("${NOTION_TOKEN}"),
1304            "Bearer ${NOTION_TOKEN}"
1305        );
1306        assert_eq!(server.headers["X-Api-Key"].format, None);
1307        assert_eq!(
1308            server.headers["X-Api-Key"].render("${API_KEY}"),
1309            "${API_KEY}"
1310        );
1311    }
1312
1313    #[test]
1314    fn mcp_server_header_format_needs_exactly_one_placeholder() {
1315        for format in ["Bearer", "Bearer {} {}"] {
1316            let error = load_mcp(&format!(
1317                "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1318                 [server.headers]\n\
1319                 Authorization = {{ from_env = \"TOKEN\", format = \"{format}\" }}\n"
1320            ))
1321            .unwrap_err()
1322            .to_string();
1323            assert!(error.contains("Authorization"), "{format}: {error}");
1324        }
1325    }
1326
1327    #[test]
1328    fn mcp_server_headers_are_refused_on_stdio() {
1329        let error = load_mcp(&format!(
1330            "{MCP_HEAD}[server]\ncommand = \"npx\"\n\
1331             [server.headers]\nAuthorization = {{ from_env = \"TOKEN\" }}\n"
1332        ))
1333        .unwrap_err()
1334        .to_string();
1335        assert!(error.contains("http"), "{error}");
1336    }
1337
1338    /// A server without headers has to serialize exactly as it did before
1339    /// the field existed, or every installed record drifts on upgrade.
1340    #[test]
1341    fn a_server_without_headers_serializes_without_the_table() {
1342        let server = load_mcp(&format!("{MCP_HEAD}[server]\ncommand = \"npx\"\n"))
1343            .unwrap()
1344            .server
1345            .unwrap();
1346        let wire = toml::to_string_pretty(&server).unwrap();
1347        assert!(!wire.contains("headers"), "{wire}");
1348    }
1349
1350    #[test]
1351    fn capability_type_round_trips_the_hyphenated_name() {
1352        assert_eq!(CapabilityType::McpServer.as_str(), "mcp-server");
1353        assert_eq!(
1354            CapabilityType::parse("mcp-server"),
1355            Some(CapabilityType::McpServer)
1356        );
1357        assert_eq!(
1358            CapabilityType::parse("mcp"),
1359            Some(CapabilityType::McpServer)
1360        );
1361        #[derive(serde::Serialize)]
1362        struct Wire {
1363            #[serde(rename = "type")]
1364            capability_type: CapabilityType,
1365        }
1366        let wire = toml::to_string(&Wire {
1367            capability_type: CapabilityType::McpServer,
1368        })
1369        .unwrap();
1370        assert!(wire.contains("type = \"mcp-server\""), "{wire}");
1371        assert_eq!(CapabilityType::parse("workflow"), None);
1372    }
1373}