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