Skip to main content

mcp_methods/server/
manifest.rs

1//! YAML manifest schema + loader.
2//!
3//! A manifest is a YAML file declaring the tools, source roots, custom
4//! embedder, and trust gates the server should apply. The loader parses,
5//! validates, and returns a [`Manifest`]; consumers (CLI wiring, tool
6//! registration) operate on the validated structure.
7//!
8//! Path strings (`source_root`, `python:` tool paths, embedder module)
9//! are kept as the raw user input — relative-to-yaml resolution happens
10//! at the use site so the data stays pure and testable.
11//!
12//! Validation is fail-fast and user-facing: the caller surfaces
13//! [`ManifestError`] messages directly to the operator.
14//!
15//! Schema mirrors the Python `kglite.mcp_server.manifest` module 1:1 so
16//! a manifest written for the Python server boots unchanged on the new
17//! Rust server.
18
19// A handful of fields/helpers are exposed for downstream consumers
20// (e.g. kglite-mcp-server reads `CypherTool::cypher` directly when
21// registering manifest-declared tools) and so look unused from this
22// crate's perspective. Silence dead-code warnings rather than chase
23// every cross-crate use.
24#![allow(dead_code)]
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use serde::Deserialize;
31use thiserror::Error;
32
33const ALLOWED_TOP_KEYS: &[&str] = &[
34    "name",
35    "instructions",
36    "overview_prefix",
37    "source_root",
38    "source_roots",
39    "trust",
40    "tools",
41    "embedder",
42    "builtins",
43    "env_file",
44    "workspace",
45    "extensions",
46    "skills",
47];
48const ALLOWED_WORKSPACE_KEYS: &[&str] = &[
49    "kind",
50    "root",
51    "watch",
52    "applies_to",
53    "sandbox_root",
54    "adopt_client_roots",
55];
56const VALID_WORKSPACE_KIND: &[&str] = &["github", "local"];
57const ALLOWED_TRUST_KEYS: &[&str] = &["allow_python_tools", "allow_embedder"];
58const ALLOWED_TOOL_KEYS: &[&str] = &[
59    "name",
60    "description",
61    "parameters",
62    "cypher",
63    "python",
64    "function",
65    "bundled",
66    "hidden",
67    // 0.3.34: per-deployment rename for bundled tools (the bundled
68    // override block already covers `description` and `hidden`; this
69    // adds the third axis — what the agent sees in `tools/list`).
70    "rename",
71];
72const ALLOWED_EMBEDDER_KEYS: &[&str] = &["module", "class", "kwargs"];
73const ALLOWED_BUILTIN_KEYS: &[&str] = &["save_graph", "temp_cleanup", "screen_stargazers"];
74const VALID_TEMP_CLEANUP: &[&str] = &["never", "on_overview"];
75
76#[derive(Debug, Error)]
77#[error("{path}: {message}")]
78pub struct ManifestError {
79    pub path: String,
80    pub message: String,
81}
82
83impl ManifestError {
84    pub fn at(path: &Path, message: impl Into<String>) -> Self {
85        Self {
86            path: path.display().to_string(),
87            message: message.into(),
88        }
89    }
90
91    pub fn bare(message: impl Into<String>) -> Self {
92        Self {
93            path: "<manifest>".to_string(),
94            message: message.into(),
95        }
96    }
97}
98
99#[derive(Debug, Default, Clone)]
100pub struct TrustConfig {
101    pub allow_python_tools: bool,
102    pub allow_embedder: bool,
103}
104
105#[derive(Debug, Clone)]
106pub enum ToolSpec {
107    Cypher(CypherTool),
108    Python(PythonTool),
109    /// Override the agent-facing surface of a bundled tool (one the
110    /// downstream binary provides natively — `cypher_query`,
111    /// `graph_overview`, `read_source`, etc.). The framework parses
112    /// the override but does not enforce that the named tool exists;
113    /// the downstream consumer (e.g. `kglite-mcp-server`) is
114    /// responsible for validating the name against its bundled
115    /// catalogue at boot time and applying the override when
116    /// emitting `tools/list`.
117    ///
118    /// Pre-0.3.31 the only customisation path for the bundled tool
119    /// surface was the manifest's global `instructions:` block —
120    /// useful for first-message orientation but not attached to
121    /// individual tools. Bundled overrides let operators rewrite a
122    /// specific tool's `description` (what the agent sees in
123    /// `tools/list`) or `hidden`-flag it out entirely.
124    Bundled(BundledOverride),
125}
126
127impl ToolSpec {
128    pub fn name(&self) -> &str {
129        match self {
130            ToolSpec::Cypher(t) => &t.name,
131            ToolSpec::Python(t) => &t.name,
132            ToolSpec::Bundled(t) => &t.name,
133        }
134    }
135}
136
137#[derive(Debug, Clone)]
138pub struct CypherTool {
139    pub name: String,
140    pub cypher: String,
141    pub description: Option<String>,
142    pub parameters: Option<serde_json::Value>,
143}
144
145#[derive(Debug, Clone)]
146pub struct PythonTool {
147    pub name: String,
148    pub python: String,
149    pub function: String,
150    pub description: Option<String>,
151    pub parameters: Option<serde_json::Value>,
152}
153
154#[derive(Debug, Clone)]
155pub struct BundledOverride {
156    /// Name of the bundled tool to override (e.g. `cypher_query`,
157    /// `repo_management`). Validation against the downstream
158    /// binary's actual catalogue happens at the consumer's boot
159    /// time — the framework only checks shape here.
160    pub name: String,
161    /// New agent-facing description that replaces the bundled
162    /// tool's default. `None` means "do not override; keep the
163    /// default."
164    pub description: Option<String>,
165    /// When true, the downstream consumer should omit this tool
166    /// from `tools/list` AND reject calls to it. Defaults to
167    /// false (visible).
168    pub hidden: bool,
169    /// Per-deployment rename: expose the bundled tool to the agent
170    /// under this name instead of its canonical name. `None` keeps
171    /// the canonical name. Lets operators running multiple kglite
172    /// servers (each backed by a different graph) disambiguate
173    /// otherwise-identical tool surfaces — without rename, an agent
174    /// running three servers sees three copies of `cypher_query`,
175    /// each indistinguishable in ToolSearch results. With rename,
176    /// the same servers can expose `legal_cypher_query`,
177    /// `prospect_cypher_query`, `open_source_cypher_query`.
178    /// Must be a valid identifier (`^[a-zA-Z_][a-zA-Z0-9_]*$`);
179    /// validation against duplicates across the manifest's tools is
180    /// the downstream consumer's responsibility.
181    pub rename: Option<String>,
182}
183
184#[derive(Debug, Clone)]
185pub struct EmbedderConfig {
186    pub module: String,
187    pub class: String,
188    pub kwargs: serde_json::Map<String, serde_json::Value>,
189}
190
191#[derive(Debug, Clone)]
192pub struct BuiltinsConfig {
193    pub save_graph: bool,
194    pub temp_cleanup: TempCleanup,
195    /// Register the `screen_stargazers` GitHub tool. Default on; set
196    /// `builtins.screen_stargazers: false` to keep the other GitHub tools
197    /// (`github_issues` / `github_api`) but drop stargazer screening.
198    pub screen_stargazers: bool,
199}
200
201impl Default for BuiltinsConfig {
202    fn default() -> Self {
203        Self {
204            save_graph: false,
205            temp_cleanup: TempCleanup::default(),
206            screen_stargazers: true,
207        }
208    }
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub enum TempCleanup {
213    #[default]
214    Never,
215    OnOverview,
216}
217
218impl TempCleanup {
219    pub fn as_str(&self) -> &'static str {
220        match self {
221            TempCleanup::Never => "never",
222            TempCleanup::OnOverview => "on_overview",
223        }
224    }
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
228pub enum WorkspaceKind {
229    /// Clone-and-track GitHub repos. The default when no `workspace:`
230    /// block is set and the operator passed `--workspace DIR`.
231    #[default]
232    Github,
233    /// Bind a fixed local directory as the active source root. No
234    /// cloning happens; `set_root_dir(path)` swaps the active root.
235    Local,
236}
237
238impl WorkspaceKind {
239    pub fn as_str(&self) -> &'static str {
240        match self {
241            WorkspaceKind::Github => "github",
242            WorkspaceKind::Local => "local",
243        }
244    }
245}
246
247#[derive(Debug, Clone, Default)]
248pub struct WorkspaceConfig {
249    pub kind: WorkspaceKind,
250    /// Local-mode only: path to the directory to bind as the source
251    /// root. Relative paths resolve against the YAML's parent dir.
252    pub root: Option<String>,
253    /// Local-mode only: wire the framework's file watcher to `root`
254    /// (debounced rebuild trigger via the post-activate hook).
255    pub watch: bool,
256    /// Local-mode only: the outer containment boundary for runtime root
257    /// swaps. When set, `set_root_dir` refuses any target that does not
258    /// resolve inside this directory; `root` itself must be inside it or
259    /// the server refuses to boot. Relative paths resolve against the
260    /// YAML's parent dir, exactly like `root`.
261    ///
262    /// **Unset is the default and means unbounded** — `set_root_dir`
263    /// accepts any directory, which is the historical behaviour every
264    /// existing deployment relies on.
265    pub sandbox_root: Option<String>,
266    /// Local-mode only: adopt a root advertised by the MCP client
267    /// (`roots/list`) when the operator configured none.
268    ///
269    /// Off by default, and **fallback-only** even when on: `workspace.root`,
270    /// `--watch`, `--source-root` and `--workspace` all win, and an explicit
271    /// `set_root_dir` permanently ends adoption for the session. With it on,
272    /// `workspace.root` may be omitted — the server then boots unanchored and
273    /// binds nothing until a client offers a root (and stays unanchored if
274    /// none ever arrives). Without it, a missing `workspace.root` is the same
275    /// boot error it has always been.
276    ///
277    /// Pair it with [`sandbox_root`](Self::sandbox_root): the client's root is
278    /// a suggestion, the boundary is what actually contains it.
279    ///
280    /// **Deprecated upstream.** MCP `roots` is deprecated as of protocol
281    /// revision `2026-07-28` (SEP-2577) — "New implementations SHOULD NOT
282    /// adopt it" — and is eligible for removal in the first revision released
283    /// on or after 2027-07-28. The migration path named by the spec is to pass
284    /// directories via tool parameters, resource URIs, or server configuration
285    /// (`workspace.root`).
286    pub adopt_client_roots: bool,
287    /// Optional opt-in for the [`find_workspace_manifest`] parent-walk
288    /// fallback. When set, this manifest is auto-discovered by
289    /// ``mcp-server --workspace DIR`` (and similar callers) only when
290    /// the operator's ``DIR`` matches the declaration here. When
291    /// unset, the parent-walk fallback NEVER fires for this manifest
292    /// — operators must pass ``--mcp-config`` explicitly.
293    ///
294    /// Values are glob patterns matching the workspace dir's basename
295    /// (single-segment match — parent-walk is always single-level).
296    /// Three forms:
297    ///
298    /// - **Single pattern** (`./repos`, `repos`, `*`, `a*`, `prod-?`):
299    ///   match against the workspace dir's basename. Literal strings
300    ///   like `repos` match only `repos`; glob patterns like `*` or
301    ///   `prod-*` match any name fitting the pattern.
302    /// - **List of patterns** (`[./repos, ./clones]`, `[prod-*, test-*]`):
303    ///   match if any pattern matches. Useful for curated subsets or
304    ///   multiple naming conventions in one manifest.
305    ///
306    /// Leading `./` is optional and stripped at parse time. Patterns
307    /// must be single-segment — `./a/b` is rejected. Invalid glob
308    /// syntax is rejected at parse time.
309    ///
310    /// Eliminates the accidental-discovery footgun where a workspace
311    /// manifest is auto-picked-up by an unrelated sibling dir. The
312    /// manifest's own declaration is the opt-in.
313    pub applies_to: Option<AppliesTo>,
314}
315
316/// Declaration of which workspace dirs the manifest applies to for
317/// the [`find_workspace_manifest`] parent-walk fallback. See
318/// [`WorkspaceConfig::applies_to`] for the full semantics. Each
319/// entry is a glob pattern (literal or with `*` / `?` / `[abc]`)
320/// matched against the workspace dir's basename.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum AppliesTo {
323    /// Single glob pattern. Matches if the workspace dir's basename
324    /// satisfies the pattern. Literal names (`repos`) match only
325    /// that name; `*` matches anything; `prod-*` matches anything
326    /// starting with `prod-`.
327    Pattern(String),
328    /// Multiple patterns. Matches if any pattern in the list matches.
329    Patterns(Vec<String>),
330}
331
332/// One source of skills declared by the manifest. Either the magic
333/// "library bundled" token (rendered as the YAML boolean `true`), or
334/// a filesystem path resolved against the manifest's parent dir.
335///
336/// Path conventions match the rest of the manifest:
337/// - `./foo` or `foo` — relative to the manifest's parent dir
338/// - `~/foo` — home-relative (POSIX `$HOME` expansion)
339/// - `/foo` — absolute
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub enum SkillSource {
342    /// The compile-time bundled skills shipped with `mcp-methods` plus
343    /// any added by the downstream binary at registry-build time.
344    /// In YAML: a bare `true` token in the `skills:` list.
345    Bundled,
346    /// A filesystem path containing `*.md` skill files. Walked at
347    /// boot. Path resolution happens at registry-build time, not parse
348    /// time — `SkillSource::Path` stores the raw operator-declared
349    /// string for round-tripping through `Manifest::to_json()`.
350    Path(String),
351}
352
353/// The parsed value of the `skills:` field in the manifest.
354///
355/// Skills are opt-in. `SkillsSource::Disabled` is the default and
356/// matches verbatim-current MCP behavior: no `prompts/list`, no
357/// methodology surface, identical context cost to pre-skills
358/// deployments. Existing kglite manifests work unchanged.
359///
360/// When enabled, the [`crate::server::skills::Registry`] walks each
361/// source in declaration order, layering them against the
362/// project-local `<basename>.skills/` directory which is always
363/// auto-detected as the top-priority layer.
364#[derive(Debug, Clone, Default, PartialEq, Eq)]
365pub enum SkillsSource {
366    /// `skills: false` or no declaration. Skills disabled entirely.
367    #[default]
368    Disabled,
369    /// One or more sources, walked in declaration order at registry
370    /// build time. First-match-per-skill-name wins across the root
371    /// layer; the auto-detected project layer (`<basename>.skills/`
372    /// adjacent to the YAML) preempts the entire root layer.
373    Sources(Vec<SkillSource>),
374}
375
376#[derive(Debug, Clone)]
377pub struct Manifest {
378    pub yaml_path: PathBuf,
379    pub name: Option<String>,
380    pub instructions: Option<String>,
381    pub overview_prefix: Option<String>,
382    pub source_roots: Vec<String>,
383    pub trust: TrustConfig,
384    pub tools: Vec<ToolSpec>,
385    pub embedder: Option<EmbedderConfig>,
386    pub builtins: BuiltinsConfig,
387    /// Optional explicit `.env` path (relative to the YAML or absolute).
388    /// When unset, the runtime walks upward from the start directory
389    /// looking for a `.env` file.
390    pub env_file: Option<String>,
391    /// Optional explicit workspace declaration. When set, this wins
392    /// over CLI `--workspace`/`--source-root` flags interpretation
393    /// (manifest is the source of truth — same rule as `source_root:`).
394    pub workspace: Option<WorkspaceConfig>,
395    /// Raw passthrough for downstream-binary-specific manifest keys.
396    /// The framework accepts any mapping under `extensions:` and stores
397    /// it here without validating the inner keys; downstream consumers
398    /// (e.g. kglite-mcp-server) read whatever they need from this map.
399    ///
400    /// This keeps the framework's strict-unknown-key validation strong
401    /// for the surfaces it owns (`builtins`, `workspace`, …) while
402    /// letting consumers add their own configuration namespace without
403    /// per-key framework round-trips.
404    pub extensions: serde_json::Map<String, serde_json::Value>,
405    /// Opt-in skills declaration. `SkillsSource::Disabled` is the
406    /// default and preserves current MCP behavior (no `prompts/`
407    /// surface). When set to any non-`Disabled` value, downstream
408    /// binaries pass this to [`crate::server::skills::Registry`] for
409    /// loading + composition; the framework then exposes the
410    /// resulting skill set via `prompts/list` and `prompts/get`.
411    ///
412    /// Three-layer composition: the operator-declared sources here
413    /// form the root layer; the project-local `<basename>.skills/`
414    /// directory (auto-detected) preempts them. See
415    /// `dev-documentation/skills-aware-mcp.md` for the full design.
416    pub skills: SkillsSource,
417}
418
419impl Manifest {
420    /// JSON-friendly representation of the validated manifest for
421    /// FFI / RPC exposure (pyo3 wrappers, JSON-RPC bridges, etc.).
422    ///
423    /// The shape is stable across patch releases: fields can be added
424    /// non-breaking, but key renames or removals are breaking changes.
425    /// When adding a new field to `Manifest`, extend this method too —
426    /// the `to_json_shape_is_stable` test will fail until you do.
427    /// The `extensions` map is passed through unchanged; downstream
428    /// consumers parse their own namespace from it.
429    pub fn to_json(&self) -> serde_json::Value {
430        serde_json::json!({
431            "yaml_path": self.yaml_path.display().to_string(),
432            "name": self.name,
433            "instructions": self.instructions,
434            "overview_prefix": self.overview_prefix,
435            "source_roots": self.source_roots,
436            "trust": {
437                "allow_python_tools": self.trust.allow_python_tools,
438                "allow_embedder": self.trust.allow_embedder,
439            },
440            "tools": self.tools.iter().map(|t| match t {
441                ToolSpec::Cypher(c) => serde_json::json!({
442                    "kind": "cypher",
443                    "name": c.name,
444                    "cypher": c.cypher,
445                    "description": c.description,
446                    "parameters": c.parameters,
447                }),
448                ToolSpec::Python(p) => serde_json::json!({
449                    "kind": "python",
450                    "name": p.name,
451                    "python": p.python,
452                    "function": p.function,
453                    "description": p.description,
454                    "parameters": p.parameters,
455                }),
456                ToolSpec::Bundled(b) => serde_json::json!({
457                    "kind": "bundled",
458                    "name": b.name,
459                    "description": b.description,
460                    "hidden": b.hidden,
461                    "rename": b.rename,
462                }),
463            }).collect::<Vec<_>>(),
464            "embedder": self.embedder.as_ref().map(|e| serde_json::json!({
465                "module": e.module,
466                "class": e.class,
467                "kwargs": e.kwargs,
468            })),
469            "builtins": {
470                "save_graph": self.builtins.save_graph,
471                "temp_cleanup": self.builtins.temp_cleanup.as_str(),
472                "screen_stargazers": self.builtins.screen_stargazers,
473            },
474            "env_file": self.env_file,
475            "workspace": self.workspace.as_ref().map(|w| serde_json::json!({
476                "kind": w.kind.as_str(),
477                "root": w.root,
478                "watch": w.watch,
479                "applies_to": w.applies_to.as_ref().map(|a| match a {
480                    AppliesTo::Pattern(p) => serde_json::Value::String(p.clone()),
481                    AppliesTo::Patterns(ps) => serde_json::Value::Array(
482                        ps.iter().map(|p| serde_json::Value::String(p.clone())).collect()
483                    ),
484                }),
485            })),
486            "extensions": self.extensions,
487            "skills": self.skills_to_json(),
488        })
489    }
490
491    /// JSON shape for the parsed `skills:` field. Emits the operator-
492    /// declared shape unchanged (modulo normalisation), suitable for
493    /// downstream pyo3 wrappers that need to introspect what the
494    /// manifest declared without re-running the parser.
495    ///
496    /// Phase 1a (this file) emits the raw declaration only. Phase 1b
497    /// adds a separate accessor on the resolved registry that exposes
498    /// the *post-resolution* skill list with provenance — that's the
499    /// per-skill `{path, origin, frontmatter}` shape kglite asked for
500    /// in their feedback. The two surfaces are intentionally
501    /// distinct: this method describes the manifest, the
502    /// registry method describes the runtime resolution.
503    fn skills_to_json(&self) -> serde_json::Value {
504        match &self.skills {
505            SkillsSource::Disabled => serde_json::Value::Bool(false),
506            SkillsSource::Sources(sources) => {
507                let arr: Vec<serde_json::Value> = sources
508                    .iter()
509                    .map(|s| match s {
510                        SkillSource::Bundled => serde_json::Value::Bool(true),
511                        SkillSource::Path(p) => serde_json::Value::String(p.clone()),
512                    })
513                    .collect();
514                serde_json::Value::Array(arr)
515            }
516        }
517    }
518}
519
520/// Auto-detect ``<basename>_mcp.yaml`` next to a graph file.
521pub fn find_sibling_manifest(graph_path: &Path) -> Option<PathBuf> {
522    let stem = graph_path.file_stem()?;
523    let parent = graph_path.parent()?;
524    let candidate = parent.join(format!("{}_mcp.yaml", stem.to_string_lossy()));
525    if candidate.is_file() {
526        Some(candidate)
527    } else {
528        None
529    }
530}
531
532/// Auto-detect ``workspace_mcp.yaml`` for a workspace directory.
533///
534/// Checks two locations in strict priority order:
535///
536/// 1. **Primary** — ``<workspace_dir>/workspace_mcp.yaml``. The
537///    documented and recommended location. If this exists, it is
538///    returned unconditionally; the parent-walk fallback is NOT
539///    consulted even if a parent manifest also exists. No opt-in
540///    declaration required — the manifest sitting inside the
541///    workspace dir is itself the operator's intent.
542/// 2. **Parent-walk fallback** —
543///    ``<workspace_dir>/../workspace_mcp.yaml``. Triggered only when
544///    the primary is absent AND the parent manifest *declares* it
545///    applies to this specific workspace dir via the
546///    ``workspace.applies_to:`` field:
547///
548///    ```yaml
549///    # open_source/workspace_mcp.yaml
550///    workspace:
551///      kind: github
552///      applies_to: ./repos     # required for parent-walk discovery
553///    ```
554///
555///    The framework loads the parent manifest, canonicalises
556///    ``manifest.workspace.applies_to`` against the manifest's parent
557///    directory, and compares it to the actual ``workspace_dir``.
558///    Match → manifest is returned. No declaration or path mismatch
559///    → discovery returns ``None`` (operator must pass
560///    ``--mcp-config`` explicitly).
561///
562///    The natural layout for github-clone-tracker workspaces is:
563///
564///    ```text
565///    open_source/
566///    ├── workspace_mcp.yaml     # config sits beside the sandbox; declares
567///    │                          # workspace.applies_to: ./repos
568///    └── repos/                 # --workspace points here
569///    ```
570///
571///    The ``applies_to`` opt-in eliminates the accidental-discovery
572///    footgun where a manifest in a project root would auto-attach to
573///    any unrelated sibling dir. Operators who didn't author the
574///    manifest get the safe default (no auto-detection); operators
575///    who did get the ergonomic UX (no ``--mcp-config`` boilerplate).
576///
577/// Bounded to one level up; will not walk past the filesystem root.
578/// Symlink-safe via canonicalisation. Added per kglite operator
579/// feedback after the 0.6.x → 0.9.x migration audit.
580pub fn find_workspace_manifest(workspace_dir: &Path) -> Option<PathBuf> {
581    let primary = workspace_dir.join("workspace_mcp.yaml");
582    if primary.is_file() {
583        return Some(primary);
584    }
585    // Parent-walk fallback. Compare against canonicalised paths to
586    // handle "/" (where parent == self) and symlinks consistently.
587    let parent = workspace_dir.parent()?;
588    let workspace_resolved = workspace_dir.canonicalize().ok()?;
589    let parent_resolved = parent.canonicalize().ok()?;
590    if parent_resolved == workspace_resolved {
591        // No real parent (filesystem root).
592        return None;
593    }
594    let fallback = parent.join("workspace_mcp.yaml");
595    if !fallback.is_file() {
596        return None;
597    }
598
599    // The fallback manifest must declare workspace.applies_to and
600    // that declaration must canonicalise to the actual workspace_dir.
601    // Otherwise the discovery is unsafe (could be accidental).
602    let manifest = match load(&fallback) {
603        Ok(m) => m,
604        Err(e) => {
605            tracing::warn!(
606                manifest = %fallback.display(),
607                error = %e,
608                "parent-walk manifest exists but failed to parse; ignoring"
609            );
610            return None;
611        }
612    };
613    let declared = manifest
614        .workspace
615        .as_ref()
616        .and_then(|w| w.applies_to.as_ref());
617    let Some(declared_applies_to) = declared else {
618        tracing::info!(
619            manifest = %fallback.display(),
620            "parent-walk manifest does not declare workspace.applies_to; \
621             ignoring (set workspace.applies_to: <pattern> to opt in)"
622        );
623        return None;
624    };
625    // Match the workspace dir's basename against the declared pattern(s).
626    // The parent-walk guarantee (workspace_dir.parent() == manifest_dir)
627    // is already established above — only the basename match is left.
628    let Some(basename) = workspace_resolved.file_name().and_then(|n| n.to_str()) else {
629        return None; // path with no usable basename, defensive
630    };
631    let patterns: Vec<&str> = match declared_applies_to {
632        AppliesTo::Pattern(p) => vec![p.as_str()],
633        AppliesTo::Patterns(ps) => ps.iter().map(String::as_str).collect(),
634    };
635    let matched = patterns.iter().any(|pat| {
636        match globset::Glob::new(pat) {
637            Ok(g) => g.compile_matcher().is_match(basename),
638            Err(_) => {
639                // Should not happen — patterns were validated at parse
640                // time. Defensive: treat as non-match.
641                false
642            }
643        }
644    });
645    if matched {
646        tracing::info!(
647            workspace_dir = %workspace_dir.display(),
648            manifest = %fallback.display(),
649            "manifest discovered via parent-walk fallback (workspace.applies_to matched)"
650        );
651        Some(fallback)
652    } else {
653        tracing::info!(
654            workspace_dir = %workspace_resolved.display(),
655            manifest = %fallback.display(),
656            basename = %basename,
657            patterns = ?patterns,
658            "parent-walk manifest's workspace.applies_to does not match \
659             this workspace_dir's basename; ignoring"
660        );
661        None
662    }
663}
664
665/// Parse and validate a manifest YAML file.
666pub fn load(yaml_path: &Path) -> Result<Manifest, ManifestError> {
667    let text = fs::read_to_string(yaml_path)
668        .map_err(|e| ManifestError::at(yaml_path, format!("read error: {e}")))?;
669    let raw: serde_yaml::Value = serde_yaml::from_str(&text)
670        .map_err(|e| ManifestError::at(yaml_path, format!("YAML parse error: {e}")))?;
671    let raw = match raw {
672        serde_yaml::Value::Null => serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
673        v => v,
674    };
675    let map = raw
676        .as_mapping()
677        .ok_or_else(|| ManifestError::at(yaml_path, "top-level must be a mapping"))?;
678    build(map, yaml_path)
679}
680
681fn build(raw: &serde_yaml::Mapping, yaml_path: &Path) -> Result<Manifest, ManifestError> {
682    check_keys(raw, ALLOWED_TOP_KEYS, "top-level keys", yaml_path)?;
683
684    if raw.contains_key("source_root") && raw.contains_key("source_roots") {
685        return Err(ManifestError::at(
686            yaml_path,
687            "specify either source_root (str) or source_roots (list), not both",
688        ));
689    }
690
691    let mut source_roots: Vec<String> = Vec::new();
692    if let Some(v) = raw.get("source_root") {
693        let s = v.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
694            ManifestError::at(yaml_path, "source_root must be a non-empty string")
695        })?;
696        source_roots.push(s.to_string());
697    } else if let Some(v) = raw.get("source_roots") {
698        let seq = v.as_sequence().ok_or_else(|| {
699            ManifestError::at(
700                yaml_path,
701                "source_roots must be a list of non-empty strings",
702            )
703        })?;
704        if seq.is_empty() {
705            return Err(ManifestError::at(
706                yaml_path,
707                "source_roots must be non-empty when set",
708            ));
709        }
710        for item in seq {
711            let s = item.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
712                ManifestError::at(
713                    yaml_path,
714                    "source_roots must be a list of non-empty strings",
715                )
716            })?;
717            source_roots.push(s.to_string());
718        }
719    }
720
721    let trust = build_trust(raw.get("trust"), yaml_path)?;
722    let tools = build_tools(raw.get("tools"), yaml_path)?;
723    let embedder = build_embedder(raw.get("embedder"), yaml_path)?;
724    let builtins = build_builtins(raw.get("builtins"), yaml_path)?;
725    let workspace = build_workspace(raw.get("workspace"), yaml_path)?;
726    let extensions = build_extensions(raw.get("extensions"), yaml_path)?;
727    let skills = build_skills(raw.get("skills"), yaml_path)?;
728
729    Ok(Manifest {
730        yaml_path: yaml_path.to_path_buf(),
731        name: optional_str(raw, "name", yaml_path)?,
732        instructions: optional_str(raw, "instructions", yaml_path)?,
733        overview_prefix: optional_str(raw, "overview_prefix", yaml_path)?,
734        source_roots,
735        trust,
736        tools,
737        embedder,
738        builtins,
739        env_file: optional_str(raw, "env_file", yaml_path)?,
740        workspace,
741        extensions,
742        skills,
743    })
744}
745
746/// Parse the polymorphic `skills:` field. Accepts:
747///
748/// - **Absent or `false`** → [`SkillsSource::Disabled`]. Pure-current
749///   MCP behavior. This is the default and what existing deployments
750///   resolve to without any YAML change.
751/// - **`skills: true`** → single bundled source. Sugar for
752///   `skills: [true]`.
753/// - **`skills: <path-string>`** → single path source. Sugar for
754///   `skills: [<path>]`.
755/// - **`skills: [bool, string, ...]`** → ordered list. Booleans MUST
756///   be `true` (the bundled marker); `false` is rejected at parse
757///   time as nonsense in list context. Each path is stored verbatim
758///   as the operator wrote it; resolution against the manifest's
759///   parent dir happens at registry-build time, not here.
760///
761/// Empty lists are accepted and parsed as `SkillsSource::Sources(vec![])`;
762/// the registry treats them as "skills opted in but no root layer,"
763/// meaning the project-local `<basename>.skills/` auto-detection
764/// still fires while the bundled + custom-path layers stay empty.
765/// Useful for operators who want to rely solely on adjacent project
766/// skills.
767fn build_skills(
768    raw: Option<&serde_yaml::Value>,
769    yaml_path: &Path,
770) -> Result<SkillsSource, ManifestError> {
771    use serde_yaml::Value;
772
773    match raw {
774        None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(SkillsSource::Disabled),
775        Some(Value::Bool(true)) => Ok(SkillsSource::Sources(vec![SkillSource::Bundled])),
776        Some(Value::String(s)) => {
777            if s.is_empty() {
778                return Err(ManifestError::at(
779                    yaml_path,
780                    "skills: path must be a non-empty string",
781                ));
782            }
783            Ok(SkillsSource::Sources(vec![SkillSource::Path(s.clone())]))
784        }
785        Some(Value::Sequence(seq)) => {
786            let mut sources = Vec::with_capacity(seq.len());
787            for (idx, item) in seq.iter().enumerate() {
788                match item {
789                    Value::Bool(true) => sources.push(SkillSource::Bundled),
790                    Value::Bool(false) => {
791                        return Err(ManifestError::at(
792                            yaml_path,
793                            format!(
794                                "skills[{idx}]: `false` is not a valid entry in a `skills:` \
795                                 list (only `true` for bundled, or a path string)"
796                            ),
797                        ));
798                    }
799                    Value::String(s) => {
800                        if s.is_empty() {
801                            return Err(ManifestError::at(
802                                yaml_path,
803                                format!("skills[{idx}]: path must be a non-empty string"),
804                            ));
805                        }
806                        sources.push(SkillSource::Path(s.clone()));
807                    }
808                    _ => {
809                        return Err(ManifestError::at(
810                            yaml_path,
811                            format!(
812                                "skills[{idx}]: each entry must be `true` (for bundled) or a \
813                                 path string"
814                            ),
815                        ));
816                    }
817                }
818            }
819            Ok(SkillsSource::Sources(sources))
820        }
821        Some(_) => Err(ManifestError::at(
822            yaml_path,
823            "skills must be `false`, `true`, a path string, or a list of \
824             (true | path string) entries",
825        )),
826    }
827}
828
829fn build_extensions(
830    raw: Option<&serde_yaml::Value>,
831    yaml_path: &Path,
832) -> Result<serde_json::Map<String, serde_json::Value>, ManifestError> {
833    let Some(raw) = raw else {
834        return Ok(serde_json::Map::new());
835    };
836    if matches!(raw, serde_yaml::Value::Null) {
837        return Ok(serde_json::Map::new());
838    }
839    if !raw.is_mapping() {
840        return Err(ManifestError::at(
841            yaml_path,
842            "extensions must be a mapping (downstream-binary-specific keys)",
843        ));
844    }
845    match yaml_to_json(raw.clone())? {
846        serde_json::Value::Object(o) => Ok(o),
847        _ => Err(ManifestError::at(yaml_path, "extensions must be a mapping")),
848    }
849}
850
851fn build_workspace(
852    raw: Option<&serde_yaml::Value>,
853    yaml_path: &Path,
854) -> Result<Option<WorkspaceConfig>, ManifestError> {
855    let Some(raw) = raw else { return Ok(None) };
856    if matches!(raw, serde_yaml::Value::Null) {
857        return Ok(None);
858    }
859    let map = raw
860        .as_mapping()
861        .ok_or_else(|| ManifestError::at(yaml_path, "workspace must be a mapping"))?;
862    check_keys(map, ALLOWED_WORKSPACE_KEYS, "workspace keys", yaml_path)?;
863    let kind = match map.get("kind") {
864        None | Some(serde_yaml::Value::Null) => WorkspaceKind::default(),
865        Some(serde_yaml::Value::String(s)) => match s.as_str() {
866            "github" => WorkspaceKind::Github,
867            "local" => WorkspaceKind::Local,
868            other => {
869                return Err(ManifestError::at(
870                    yaml_path,
871                    format!(
872                        "workspace.kind must be one of {VALID_WORKSPACE_KIND:?}, got {other:?}"
873                    ),
874                ));
875            }
876        },
877        Some(_) => {
878            return Err(ManifestError::at(
879                yaml_path,
880                format!("workspace.kind must be one of {VALID_WORKSPACE_KIND:?}"),
881            ))
882        }
883    };
884    let root = match map.get("root") {
885        None | Some(serde_yaml::Value::Null) => None,
886        Some(serde_yaml::Value::String(s)) if !s.is_empty() => Some(s.clone()),
887        _ => {
888            return Err(ManifestError::at(
889                yaml_path,
890                "workspace.root must be a non-empty string",
891            ))
892        }
893    };
894    let sandbox_root = match map.get("sandbox_root") {
895        None | Some(serde_yaml::Value::Null) => None,
896        Some(serde_yaml::Value::String(s)) if !s.is_empty() => Some(s.clone()),
897        _ => {
898            return Err(ManifestError::at(
899                yaml_path,
900                "workspace.sandbox_root must be a non-empty string",
901            ))
902        }
903    };
904    let watch = match map.get("watch") {
905        None | Some(serde_yaml::Value::Null) => false,
906        Some(serde_yaml::Value::Bool(b)) => *b,
907        Some(_) => {
908            return Err(ManifestError::at(
909                yaml_path,
910                "workspace.watch must be a bool",
911            ))
912        }
913    };
914    let adopt_client_roots = match map.get("adopt_client_roots") {
915        None | Some(serde_yaml::Value::Null) => false,
916        Some(serde_yaml::Value::Bool(b)) => *b,
917        Some(_) => {
918            return Err(ManifestError::at(
919                yaml_path,
920                "workspace.adopt_client_roots must be a bool",
921            ))
922        }
923    };
924    let applies_to =
925        match map.get("applies_to") {
926            None | Some(serde_yaml::Value::Null) => None,
927            Some(serde_yaml::Value::String(s)) => {
928                Some(AppliesTo::Pattern(parse_applies_to_pattern(s, yaml_path)?))
929            }
930            Some(serde_yaml::Value::Sequence(seq)) => {
931                if seq.is_empty() {
932                    return Err(ManifestError::at(
933                        yaml_path,
934                        "workspace.applies_to: list must contain at least one pattern",
935                    ));
936                }
937                let mut patterns = Vec::with_capacity(seq.len());
938                for (i, item) in seq.iter().enumerate() {
939                    let s = item.as_str().ok_or_else(|| {
940                        ManifestError::at(
941                            yaml_path,
942                            format!("workspace.applies_to[{i}] must be a string"),
943                        )
944                    })?;
945                    let cleaned = parse_applies_to_pattern(s, yaml_path).map_err(|e| {
946                        ManifestError::at(
947                            yaml_path,
948                            format!("workspace.applies_to[{i}]: {}", e.message),
949                        )
950                    })?;
951                    patterns.push(cleaned);
952                }
953                Some(AppliesTo::Patterns(patterns))
954            }
955            _ => return Err(ManifestError::at(
956                yaml_path,
957                "workspace.applies_to must be a non-empty string (a pattern) or a list of patterns",
958            )),
959        };
960    // `adopt_client_roots` is the *only* thing that relaxes this: with it
961    // set, the root is expected to arrive from the client, so its absence
962    // is a deliberate configuration rather than a forgotten key. A plain
963    // manifest missing `root` still fails exactly as it always has.
964    if kind == WorkspaceKind::Local && root.is_none() && !adopt_client_roots {
965        return Err(ManifestError::at(
966            yaml_path,
967            "workspace.kind: local requires workspace.root to be set",
968        ));
969    }
970    // `watch` needs something to watch, and `adopt_client_roots` is the
971    // only way to reach this shape (a rootless local manifest is refused
972    // above). Enforced *here*, in the loader every consumer goes through,
973    // because that is where the schema reference says the rule lives — a
974    // library consumer that builds its own workspace from a loaded
975    // manifest never reaches `mcp-server`'s mode resolution, and would
976    // otherwise get a silently dead watcher.
977    if kind == WorkspaceKind::Local && watch && root.is_none() {
978        return Err(ManifestError::at(
979            yaml_path,
980            "workspace.watch requires workspace.root — an adoption-only \
981             workspace has nothing to watch at boot",
982        ));
983    }
984    if kind == WorkspaceKind::Github && watch {
985        return Err(ManifestError::at(
986            yaml_path,
987            "workspace.watch is only valid with workspace.kind: local",
988        ));
989    }
990    if kind == WorkspaceKind::Github && sandbox_root.is_some() {
991        return Err(ManifestError::at(
992            yaml_path,
993            "workspace.sandbox_root is only valid with workspace.kind: local",
994        ));
995    }
996    if kind == WorkspaceKind::Github && adopt_client_roots {
997        return Err(ManifestError::at(
998            yaml_path,
999            "workspace.adopt_client_roots is only valid with workspace.kind: local",
1000        ));
1001    }
1002    Ok(Some(WorkspaceConfig {
1003        kind,
1004        root,
1005        watch,
1006        applies_to,
1007        sandbox_root,
1008        adopt_client_roots,
1009    }))
1010}
1011
1012/// Parse + validate a single ``workspace.applies_to`` entry. Accepts
1013/// any glob pattern matching a single path segment (no embedded
1014/// slashes, no `..`). The leading ``./`` is optional and stripped.
1015/// Validates glob syntax via `globset::Glob::new` so invalid patterns
1016/// surface clear errors at boot.
1017///
1018/// Returns the cleaned pattern string (without `./` prefix) on
1019/// success.
1020fn parse_applies_to_pattern(raw: &str, yaml_path: &Path) -> Result<String, ManifestError> {
1021    let trimmed = raw.trim();
1022    if trimmed.is_empty() {
1023        return Err(ManifestError::at(
1024            yaml_path,
1025            "workspace.applies_to: pattern must not be empty",
1026        ));
1027    }
1028    // Strip a single leading `./` for ergonomic equivalence between
1029    // `./repos` and `repos`. Both forms commonly appear in operator
1030    // muscle memory; normalise so storage + glob matching is uniform.
1031    let stripped = trimmed.strip_prefix("./").unwrap_or(trimmed);
1032    if stripped.is_empty() {
1033        return Err(ManifestError::at(
1034            yaml_path,
1035            "workspace.applies_to: pattern must not be empty after stripping `./` prefix",
1036        ));
1037    }
1038    if stripped.contains('/') {
1039        return Err(ManifestError::at(
1040            yaml_path,
1041            format!(
1042                "workspace.applies_to: pattern {raw:?} must be a single path segment \
1043                 (no embedded `/`) — parent-walk discovery is bounded to one level"
1044            ),
1045        ));
1046    }
1047    if stripped == ".." || stripped.starts_with("../") {
1048        return Err(ManifestError::at(
1049            yaml_path,
1050            format!("workspace.applies_to: pattern {raw:?} must not contain `..`"),
1051        ));
1052    }
1053    if Path::new(stripped).is_absolute() {
1054        return Err(ManifestError::at(
1055            yaml_path,
1056            format!("workspace.applies_to: pattern {raw:?} must be relative, not absolute"),
1057        ));
1058    }
1059    // Validate glob syntax. Construct a Glob to surface any syntax
1060    // errors immediately — we don't keep the compiled form (cheap to
1061    // re-compile at match time, keeps `WorkspaceConfig` Clone-cheap).
1062    globset::Glob::new(stripped).map_err(|e| {
1063        ManifestError::at(
1064            yaml_path,
1065            format!("workspace.applies_to: invalid glob pattern {raw:?}: {e}"),
1066        )
1067    })?;
1068    Ok(stripped.to_string())
1069}
1070
1071fn check_keys(
1072    map: &serde_yaml::Mapping,
1073    allowed: &[&str],
1074    label: &str,
1075    yaml_path: &Path,
1076) -> Result<(), ManifestError> {
1077    let mut unknown: Vec<String> = Vec::new();
1078    for (k, _) in map {
1079        let key = k.as_str().unwrap_or("<non-string-key>");
1080        if !allowed.contains(&key) {
1081            unknown.push(key.to_string());
1082        }
1083    }
1084    if !unknown.is_empty() {
1085        unknown.sort();
1086        return Err(ManifestError::at(
1087            yaml_path,
1088            format!("unknown {label}: {unknown:?}. Allowed: {allowed:?}"),
1089        ));
1090    }
1091    Ok(())
1092}
1093
1094fn optional_str(
1095    raw: &serde_yaml::Mapping,
1096    key: &str,
1097    yaml_path: &Path,
1098) -> Result<Option<String>, ManifestError> {
1099    match raw.get(key) {
1100        None | Some(serde_yaml::Value::Null) => Ok(None),
1101        Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())),
1102        Some(_) => Err(ManifestError::at(
1103            yaml_path,
1104            format!("{key} must be a string"),
1105        )),
1106    }
1107}
1108
1109fn build_trust(
1110    raw: Option<&serde_yaml::Value>,
1111    yaml_path: &Path,
1112) -> Result<TrustConfig, ManifestError> {
1113    let Some(raw) = raw else {
1114        return Ok(TrustConfig::default());
1115    };
1116    let map = raw
1117        .as_mapping()
1118        .ok_or_else(|| ManifestError::at(yaml_path, "trust must be a mapping"))?;
1119    check_keys(map, ALLOWED_TRUST_KEYS, "trust keys", yaml_path)?;
1120    let mut cfg = TrustConfig::default();
1121    if let Some(v) = map.get("allow_python_tools") {
1122        cfg.allow_python_tools = v.as_bool().ok_or_else(|| {
1123            ManifestError::at(yaml_path, "trust.allow_python_tools must be a bool")
1124        })?;
1125    }
1126    if let Some(v) = map.get("allow_embedder") {
1127        cfg.allow_embedder = v
1128            .as_bool()
1129            .ok_or_else(|| ManifestError::at(yaml_path, "trust.allow_embedder must be a bool"))?;
1130    }
1131    Ok(cfg)
1132}
1133
1134fn build_tools(
1135    raw: Option<&serde_yaml::Value>,
1136    yaml_path: &Path,
1137) -> Result<Vec<ToolSpec>, ManifestError> {
1138    let Some(raw) = raw else {
1139        return Ok(Vec::new());
1140    };
1141    let seq = raw
1142        .as_sequence()
1143        .ok_or_else(|| ManifestError::at(yaml_path, "tools must be a list"))?;
1144    let mut tools: Vec<ToolSpec> = Vec::new();
1145    let mut seen: BTreeMap<String, ()> = BTreeMap::new();
1146    for (i, entry) in seq.iter().enumerate() {
1147        let tool = build_tool(entry, i, yaml_path)?;
1148        let name = tool.name().to_string();
1149        if seen.insert(name.clone(), ()).is_some() {
1150            return Err(ManifestError::at(
1151                yaml_path,
1152                format!("duplicate tool name: {name:?}"),
1153            ));
1154        }
1155        tools.push(tool);
1156    }
1157    Ok(tools)
1158}
1159
1160fn build_tool(
1161    entry: &serde_yaml::Value,
1162    idx: usize,
1163    yaml_path: &Path,
1164) -> Result<ToolSpec, ManifestError> {
1165    let map = entry
1166        .as_mapping()
1167        .ok_or_else(|| ManifestError::at(yaml_path, format!("tools[{idx}] must be a mapping")))?;
1168    check_keys(map, ALLOWED_TOOL_KEYS, "tool keys", yaml_path)?;
1169
1170    // Kind detection. `cypher` and `python` are tool-creation kinds
1171    // (operator declares a new named tool); `bundled` is a tool-
1172    // override kind (operator picks a bundled tool name and customises
1173    // its agent-facing surface). Exactly one must be present.
1174    let has_cypher = map.contains_key("cypher");
1175    let has_python = map.contains_key("python");
1176    let has_bundled = map.contains_key("bundled");
1177    let kinds_present: Vec<&str> = [
1178        ("cypher", has_cypher),
1179        ("python", has_python),
1180        ("bundled", has_bundled),
1181    ]
1182    .into_iter()
1183    .filter(|(_, p)| *p)
1184    .map(|(k, _)| k)
1185    .collect();
1186    if kinds_present.is_empty() {
1187        return Err(ManifestError::at(
1188            yaml_path,
1189            format!("tools[{idx}] needs exactly one of: [\"cypher\", \"python\", \"bundled\"]"),
1190        ));
1191    }
1192    if kinds_present.len() > 1 {
1193        return Err(ManifestError::at(
1194            yaml_path,
1195            format!("tools[{idx}] has multiple kinds set ({kinds_present:?}); pick exactly one"),
1196        ));
1197    }
1198
1199    // The `bundled` kind takes its name from the `bundled:` value
1200    // itself (e.g. `bundled: cypher_query`) and forbids the
1201    // tool-creation fields. Branch early so we don't run the
1202    // tool-creation `name:` requirement against an override entry.
1203    if has_bundled {
1204        return build_bundled_override(map, idx, yaml_path);
1205    }
1206
1207    let name = map
1208        .get("name")
1209        .and_then(|v| v.as_str())
1210        .filter(|s| valid_identifier(s))
1211        .ok_or_else(|| {
1212            ManifestError::at(
1213                yaml_path,
1214                format!("tools[{idx}] needs a string `name:` matching ^[a-zA-Z_][a-zA-Z0-9_]*$"),
1215            )
1216        })?
1217        .to_string();
1218
1219    // `hidden:` is only valid on bundled overrides (`hidden:`-flagging
1220    // a tool you're declaring inline doesn't make sense — just don't
1221    // declare it). Reject early so the operator gets a clear error.
1222    if map.contains_key("hidden") {
1223        return Err(ManifestError::at(
1224            yaml_path,
1225            format!(
1226                "tools[{idx}] ({name:?}) `hidden:` is only valid on `bundled:` override entries"
1227            ),
1228        ));
1229    }
1230
1231    let description = match map.get("description") {
1232        None | Some(serde_yaml::Value::Null) => None,
1233        Some(serde_yaml::Value::String(s)) => Some(s.clone()),
1234        Some(_) => {
1235            return Err(ManifestError::at(
1236                yaml_path,
1237                format!("tools[{idx}] ({name:?}).description must be a string"),
1238            ))
1239        }
1240    };
1241
1242    let parameters = match map.get("parameters") {
1243        None | Some(serde_yaml::Value::Null) => None,
1244        Some(v) if v.is_mapping() => Some(yaml_to_json(v.clone())?),
1245        Some(_) => {
1246            return Err(ManifestError::at(
1247                yaml_path,
1248                format!("tools[{idx}] ({name:?}).parameters must be a mapping"),
1249            ))
1250        }
1251    };
1252
1253    if has_cypher {
1254        let cypher = map
1255            .get("cypher")
1256            .and_then(|v| v.as_str())
1257            .filter(|s| !s.trim().is_empty())
1258            .ok_or_else(|| {
1259                ManifestError::at(
1260                    yaml_path,
1261                    format!("tools[{idx}] ({name:?}).cypher must be a non-empty string"),
1262                )
1263            })?
1264            .to_string();
1265        return Ok(ToolSpec::Cypher(CypherTool {
1266            name,
1267            cypher,
1268            description,
1269            parameters,
1270        }));
1271    }
1272
1273    // python tool
1274    let python = map
1275        .get("python")
1276        .and_then(|v| v.as_str())
1277        .filter(|s| !s.is_empty())
1278        .ok_or_else(|| {
1279            ManifestError::at(
1280                yaml_path,
1281                format!("tools[{idx}] ({name:?}).python must be a non-empty path string"),
1282            )
1283        })?
1284        .to_string();
1285    let function = map
1286        .get("function")
1287        .and_then(|v| v.as_str())
1288        .filter(|s| valid_identifier(s))
1289        .ok_or_else(|| {
1290            ManifestError::at(
1291                yaml_path,
1292                format!(
1293                    "tools[{idx}] ({name:?}) python tools need `function:` set to a valid Python identifier"
1294                ),
1295            )
1296        })?
1297        .to_string();
1298    Ok(ToolSpec::Python(PythonTool {
1299        name,
1300        python,
1301        function,
1302        description,
1303        parameters,
1304    }))
1305}
1306
1307/// Parse a `bundled:` override entry from `tools[idx]`. The caller
1308/// (`build_tool`) has already established that the entry has
1309/// `bundled:` set as the kind discriminator.
1310fn build_bundled_override(
1311    map: &serde_yaml::Mapping,
1312    idx: usize,
1313    yaml_path: &Path,
1314) -> Result<ToolSpec, ManifestError> {
1315    let name = map
1316        .get("bundled")
1317        .and_then(|v| v.as_str())
1318        .filter(|s| valid_identifier(s))
1319        .ok_or_else(|| {
1320            ManifestError::at(
1321                yaml_path,
1322                format!(
1323                    "tools[{idx}] `bundled:` must be a string naming a bundled tool \
1324                     (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)"
1325                ),
1326            )
1327        })?
1328        .to_string();
1329
1330    // Tool-creation fields are forbidden on override entries — the
1331    // override only customises an existing bundled tool's surface,
1332    // it doesn't declare a new tool. Catch these at parse time so
1333    // operators get a clear error rather than silent confusion.
1334    for forbidden in ["name", "parameters", "function"] {
1335        if map.contains_key(forbidden) {
1336            return Err(ManifestError::at(
1337                yaml_path,
1338                format!(
1339                    "tools[{idx}] bundled override {name:?} cannot set `{forbidden}:` \
1340                     (only `description:`, `hidden:`, and `rename:` are permitted on overrides)"
1341                ),
1342            ));
1343        }
1344    }
1345
1346    let description = match map.get("description") {
1347        None | Some(serde_yaml::Value::Null) => None,
1348        Some(serde_yaml::Value::String(s)) => Some(s.clone()),
1349        Some(_) => {
1350            return Err(ManifestError::at(
1351                yaml_path,
1352                format!("tools[{idx}] bundled override {name:?}.description must be a string"),
1353            ))
1354        }
1355    };
1356
1357    let hidden = match map.get("hidden") {
1358        None | Some(serde_yaml::Value::Null) => false,
1359        Some(serde_yaml::Value::Bool(b)) => *b,
1360        Some(_) => {
1361            return Err(ManifestError::at(
1362                yaml_path,
1363                format!("tools[{idx}] bundled override {name:?}.hidden must be a bool"),
1364            ))
1365        }
1366    };
1367
1368    // 0.3.34: optional per-deployment rename. Validated as an
1369    // identifier here; cross-tool collision check is the consumer's
1370    // job (it knows what other names — bundled, cypher, python — it
1371    // has in scope).
1372    let rename = match map.get("rename") {
1373        None | Some(serde_yaml::Value::Null) => None,
1374        Some(serde_yaml::Value::String(s)) => {
1375            if !valid_identifier(s) {
1376                return Err(ManifestError::at(
1377                    yaml_path,
1378                    format!(
1379                        "tools[{idx}] bundled override {name:?}.rename must be a valid identifier \
1380                         (^[a-zA-Z_][a-zA-Z0-9_]*$), got {s:?}"
1381                    ),
1382                ));
1383            }
1384            Some(s.clone())
1385        }
1386        Some(_) => {
1387            return Err(ManifestError::at(
1388                yaml_path,
1389                format!("tools[{idx}] bundled override {name:?}.rename must be a string"),
1390            ))
1391        }
1392    };
1393
1394    Ok(ToolSpec::Bundled(BundledOverride {
1395        name,
1396        description,
1397        hidden,
1398        rename,
1399    }))
1400}
1401
1402fn build_embedder(
1403    raw: Option<&serde_yaml::Value>,
1404    yaml_path: &Path,
1405) -> Result<Option<EmbedderConfig>, ManifestError> {
1406    let Some(raw) = raw else { return Ok(None) };
1407    if matches!(raw, serde_yaml::Value::Null) {
1408        return Ok(None);
1409    }
1410    let map = raw
1411        .as_mapping()
1412        .ok_or_else(|| ManifestError::at(yaml_path, "embedder must be a mapping"))?;
1413    check_keys(map, ALLOWED_EMBEDDER_KEYS, "embedder keys", yaml_path)?;
1414    let module = map
1415        .get("module")
1416        .and_then(|v| v.as_str())
1417        .filter(|s| !s.is_empty())
1418        .ok_or_else(|| {
1419            ManifestError::at(
1420                yaml_path,
1421                "embedder.module must be a non-empty string (path or dotted name)",
1422            )
1423        })?
1424        .to_string();
1425    let class = map
1426        .get("class")
1427        .and_then(|v| v.as_str())
1428        .filter(|s| valid_identifier(s))
1429        .ok_or_else(|| {
1430            ManifestError::at(
1431                yaml_path,
1432                "embedder.class must be a valid identifier matching ^[a-zA-Z_][a-zA-Z0-9_]*$",
1433            )
1434        })?
1435        .to_string();
1436    let kwargs = match map.get("kwargs") {
1437        None | Some(serde_yaml::Value::Null) => serde_json::Map::new(),
1438        Some(v) if v.is_mapping() => match yaml_to_json(v.clone())? {
1439            serde_json::Value::Object(o) => o,
1440            _ => {
1441                return Err(ManifestError::at(
1442                    yaml_path,
1443                    "embedder.kwargs must be a mapping",
1444                ))
1445            }
1446        },
1447        Some(_) => {
1448            return Err(ManifestError::at(
1449                yaml_path,
1450                "embedder.kwargs must be a mapping",
1451            ))
1452        }
1453    };
1454    Ok(Some(EmbedderConfig {
1455        module,
1456        class,
1457        kwargs,
1458    }))
1459}
1460
1461fn build_builtins(
1462    raw: Option<&serde_yaml::Value>,
1463    yaml_path: &Path,
1464) -> Result<BuiltinsConfig, ManifestError> {
1465    let Some(raw) = raw else {
1466        return Ok(BuiltinsConfig::default());
1467    };
1468    if matches!(raw, serde_yaml::Value::Null) {
1469        return Ok(BuiltinsConfig::default());
1470    }
1471    let map = raw
1472        .as_mapping()
1473        .ok_or_else(|| ManifestError::at(yaml_path, "builtins must be a mapping"))?;
1474    check_keys(map, ALLOWED_BUILTIN_KEYS, "builtins keys", yaml_path)?;
1475    let mut cfg = BuiltinsConfig::default();
1476    if let Some(v) = map.get("save_graph") {
1477        cfg.save_graph = v
1478            .as_bool()
1479            .ok_or_else(|| ManifestError::at(yaml_path, "builtins.save_graph must be a bool"))?;
1480    }
1481    if let Some(v) = map.get("screen_stargazers") {
1482        cfg.screen_stargazers = v.as_bool().ok_or_else(|| {
1483            ManifestError::at(yaml_path, "builtins.screen_stargazers must be a bool")
1484        })?;
1485    }
1486    if let Some(v) = map.get("temp_cleanup") {
1487        let s = v.as_str().ok_or_else(|| {
1488            ManifestError::at(
1489                yaml_path,
1490                format!("builtins.temp_cleanup must be one of {VALID_TEMP_CLEANUP:?}"),
1491            )
1492        })?;
1493        cfg.temp_cleanup = match s {
1494            "never" => TempCleanup::Never,
1495            "on_overview" => TempCleanup::OnOverview,
1496            other => {
1497                return Err(ManifestError::at(
1498                    yaml_path,
1499                    format!(
1500                        "builtins.temp_cleanup must be one of {VALID_TEMP_CLEANUP:?}, got {other:?}"
1501                    ),
1502                ))
1503            }
1504        };
1505    }
1506    Ok(cfg)
1507}
1508
1509fn valid_identifier(s: &str) -> bool {
1510    let mut chars = s.chars();
1511    match chars.next() {
1512        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1513        _ => return false,
1514    }
1515    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1516}
1517
1518fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, ManifestError> {
1519    serde_json::to_value(&v)
1520        .map_err(|e| ManifestError::bare(format!("yaml→json conversion failed: {e}")))
1521}
1522
1523#[derive(Debug, Deserialize)]
1524struct _Reserved;
1525
1526#[cfg(test)]
1527mod tests {
1528    use super::*;
1529
1530    fn write_tmp(text: &str) -> tempfile::NamedTempFile {
1531        let mut f = tempfile::NamedTempFile::new().unwrap();
1532        std::io::Write::write_all(&mut f, text.as_bytes()).unwrap();
1533        f
1534    }
1535
1536    #[test]
1537    fn loads_minimal_empty_manifest() {
1538        let f = write_tmp("");
1539        let m = load(f.path()).unwrap();
1540        assert_eq!(m.tools.len(), 0);
1541        assert_eq!(m.source_roots.len(), 0);
1542        assert!(!m.trust.allow_python_tools);
1543        assert!(!m.trust.allow_embedder);
1544        assert_eq!(m.builtins.temp_cleanup, TempCleanup::Never);
1545    }
1546
1547    #[test]
1548    fn loads_name_and_instructions() {
1549        let f = write_tmp("name: Demo\ninstructions: |\n  multi-line\n  block\n");
1550        let m = load(f.path()).unwrap();
1551        assert_eq!(m.name.as_deref(), Some("Demo"));
1552        assert!(m.instructions.unwrap().contains("multi-line"));
1553    }
1554
1555    #[test]
1556    fn rejects_unknown_top_key() {
1557        let f = write_tmp("bogus: 1\n");
1558        let err = load(f.path()).unwrap_err();
1559        assert!(err.message.contains("unknown top-level"));
1560    }
1561
1562    #[test]
1563    fn source_root_string_normalises_to_list() {
1564        let f = write_tmp("source_root: ./data\n");
1565        let m = load(f.path()).unwrap();
1566        assert_eq!(m.source_roots, vec!["./data".to_string()]);
1567    }
1568
1569    #[test]
1570    fn source_roots_list_preserved() {
1571        let f = write_tmp("source_roots:\n  - ./a\n  - ./b\n");
1572        let m = load(f.path()).unwrap();
1573        assert_eq!(m.source_roots, vec!["./a".to_string(), "./b".to_string()]);
1574    }
1575
1576    #[test]
1577    fn rejects_both_source_root_and_source_roots() {
1578        let f = write_tmp("source_root: ./a\nsource_roots: [./b]\n");
1579        assert!(load(f.path()).unwrap_err().message.contains("not both"));
1580    }
1581
1582    #[test]
1583    fn cypher_tool_parses() {
1584        let f = write_tmp("tools:\n  - name: lookup\n    cypher: MATCH (n) RETURN n\n");
1585        let m = load(f.path()).unwrap();
1586        assert_eq!(m.tools.len(), 1);
1587        match &m.tools[0] {
1588            ToolSpec::Cypher(t) => {
1589                assert_eq!(t.name, "lookup");
1590                assert!(t.cypher.contains("MATCH"));
1591            }
1592            _ => panic!("expected cypher tool"),
1593        }
1594    }
1595
1596    #[test]
1597    fn python_tool_parses() {
1598        let f =
1599            write_tmp("tools:\n  - name: detail\n    python: ./tools.py\n    function: detail\n");
1600        let m = load(f.path()).unwrap();
1601        match &m.tools[0] {
1602            ToolSpec::Python(t) => {
1603                assert_eq!(t.python, "./tools.py");
1604                assert_eq!(t.function, "detail");
1605            }
1606            _ => panic!("expected python tool"),
1607        }
1608    }
1609
1610    #[test]
1611    fn rejects_tool_with_both_kinds() {
1612        let f = write_tmp(
1613            "tools:\n  - name: x\n    cypher: 'MATCH (n) RETURN n'\n    python: ./t.py\n    function: x\n",
1614        );
1615        assert!(load(f.path())
1616            .unwrap_err()
1617            .message
1618            .contains("multiple kinds"));
1619    }
1620
1621    #[test]
1622    fn rejects_tool_with_no_kind() {
1623        let f = write_tmp("tools:\n  - name: x\n");
1624        assert!(load(f.path())
1625            .unwrap_err()
1626            .message
1627            .contains("needs exactly one"));
1628    }
1629
1630    #[test]
1631    fn rejects_duplicate_tool_names() {
1632        let f = write_tmp(
1633            "tools:\n  - name: same\n    cypher: 'MATCH (n) RETURN n'\n  - name: same\n    cypher: 'MATCH (m) RETURN m'\n",
1634        );
1635        assert!(load(f.path()).unwrap_err().message.contains("duplicate"));
1636    }
1637
1638    // ─── Bundled override shape (0.3.31) ────────────────────────
1639
1640    #[test]
1641    fn bundled_override_with_description_parses() {
1642        let f =
1643            write_tmp("tools:\n  - bundled: repo_management\n    description: \"FIRST STEP\"\n");
1644        let m = load(f.path()).unwrap();
1645        assert_eq!(m.tools.len(), 1);
1646        match &m.tools[0] {
1647            ToolSpec::Bundled(b) => {
1648                assert_eq!(b.name, "repo_management");
1649                assert_eq!(b.description.as_deref(), Some("FIRST STEP"));
1650                assert!(!b.hidden);
1651            }
1652            _ => panic!("expected bundled override"),
1653        }
1654    }
1655
1656    #[test]
1657    fn bundled_override_with_hidden_parses() {
1658        let f = write_tmp("tools:\n  - bundled: ping\n    hidden: true\n");
1659        let m = load(f.path()).unwrap();
1660        match &m.tools[0] {
1661            ToolSpec::Bundled(b) => {
1662                assert_eq!(b.name, "ping");
1663                assert!(b.hidden);
1664                assert!(b.description.is_none());
1665            }
1666            _ => panic!("expected bundled override"),
1667        }
1668    }
1669
1670    #[test]
1671    fn bundled_override_alongside_cypher_tools_parses() {
1672        let f = write_tmp(
1673            "tools:\n\
1674             \x20\x20- bundled: cypher_query\n\
1675             \x20\x20\x20\x20description: \"Custom server description\"\n\
1676             \x20\x20- name: lookup\n\
1677             \x20\x20\x20\x20cypher: \"MATCH (n) RETURN n\"\n",
1678        );
1679        let m = load(f.path()).unwrap();
1680        assert_eq!(m.tools.len(), 2);
1681        assert!(matches!(m.tools[0], ToolSpec::Bundled(_)));
1682        assert!(matches!(m.tools[1], ToolSpec::Cypher(_)));
1683    }
1684
1685    #[test]
1686    fn rejects_bundled_with_cypher_kind() {
1687        let f =
1688            write_tmp("tools:\n  - bundled: cypher_query\n    cypher: \"MATCH (n) RETURN n\"\n");
1689        let err = load(f.path()).unwrap_err();
1690        assert!(
1691            err.message.contains("multiple kinds"),
1692            "got: {}",
1693            err.message
1694        );
1695    }
1696
1697    #[test]
1698    fn rejects_bundled_with_name_field() {
1699        let f = write_tmp("tools:\n  - bundled: ping\n    name: ping\n");
1700        let err = load(f.path()).unwrap_err();
1701        assert!(
1702            err.message.contains("cannot set `name:`"),
1703            "got: {}",
1704            err.message
1705        );
1706    }
1707
1708    #[test]
1709    fn rejects_bundled_with_parameters_field() {
1710        let f =
1711            write_tmp("tools:\n  - bundled: cypher_query\n    parameters:\n      type: object\n");
1712        let err = load(f.path()).unwrap_err();
1713        assert!(
1714            err.message.contains("cannot set `parameters:`"),
1715            "got: {}",
1716            err.message
1717        );
1718    }
1719
1720    #[test]
1721    fn rejects_bundled_with_non_bool_hidden() {
1722        let f = write_tmp("tools:\n  - bundled: ping\n    hidden: yes-please\n");
1723        let err = load(f.path()).unwrap_err();
1724        assert!(
1725            err.message.contains("hidden must be a bool"),
1726            "got: {}",
1727            err.message
1728        );
1729    }
1730
1731    #[test]
1732    fn rejects_hidden_on_cypher_tool() {
1733        let f = write_tmp(
1734            "tools:\n  - name: lookup\n    cypher: \"MATCH (n) RETURN n\"\n    hidden: true\n",
1735        );
1736        let err = load(f.path()).unwrap_err();
1737        assert!(
1738            err.message
1739                .contains("`hidden:` is only valid on `bundled:` override entries"),
1740            "got: {}",
1741            err.message
1742        );
1743    }
1744
1745    #[test]
1746    fn rejects_duplicate_bundled_overrides() {
1747        // The dedup check is on tool name; two `bundled: ping` entries
1748        // share the same name and should be rejected the same way
1749        // duplicate cypher tools are.
1750        let f = write_tmp(
1751            "tools:\n  - bundled: ping\n    hidden: true\n  - bundled: ping\n    description: \"x\"\n",
1752        );
1753        assert!(load(f.path()).unwrap_err().message.contains("duplicate"));
1754    }
1755
1756    #[test]
1757    fn rejects_bundled_with_invalid_identifier() {
1758        let f = write_tmp("tools:\n  - bundled: \"123-bad\"\n    hidden: true\n");
1759        let err = load(f.path()).unwrap_err();
1760        assert!(
1761            err.message.contains("must be a string"),
1762            "got: {}",
1763            err.message
1764        );
1765    }
1766
1767    // 0.3.34 — `tools[].bundled: rename:` per-deployment override
1768    #[test]
1769    fn bundled_rename_parses_when_valid_identifier() {
1770        let f = write_tmp("tools:\n  - bundled: cypher_query\n    rename: legal_cypher_query\n");
1771        let m = load(f.path()).unwrap();
1772        match &m.tools[0] {
1773            ToolSpec::Bundled(b) => {
1774                assert_eq!(b.name, "cypher_query");
1775                assert_eq!(b.rename.as_deref(), Some("legal_cypher_query"));
1776                assert!(!b.hidden);
1777                assert!(b.description.is_none());
1778            }
1779            _ => panic!("expected bundled override"),
1780        }
1781    }
1782
1783    #[test]
1784    fn bundled_rename_alongside_description_parses() {
1785        let f = write_tmp(
1786            "tools:\n  - bundled: cypher_query\n    rename: legal_cypher_query\n    description: \"Legal-corpus cypher\"\n",
1787        );
1788        let m = load(f.path()).unwrap();
1789        match &m.tools[0] {
1790            ToolSpec::Bundled(b) => {
1791                assert_eq!(b.rename.as_deref(), Some("legal_cypher_query"));
1792                assert_eq!(b.description.as_deref(), Some("Legal-corpus cypher"));
1793            }
1794            _ => panic!("expected bundled override"),
1795        }
1796    }
1797
1798    #[test]
1799    fn bundled_rename_defaults_to_none() {
1800        let f = write_tmp("tools:\n  - bundled: cypher_query\n    description: \"x\"\n");
1801        let m = load(f.path()).unwrap();
1802        match &m.tools[0] {
1803            ToolSpec::Bundled(b) => assert!(b.rename.is_none()),
1804            _ => panic!("expected bundled override"),
1805        }
1806    }
1807
1808    #[test]
1809    fn rejects_bundled_rename_with_invalid_identifier() {
1810        let f = write_tmp("tools:\n  - bundled: cypher_query\n    rename: \"123-bad\"\n");
1811        let err = load(f.path()).unwrap_err();
1812        assert!(
1813            err.message.contains("rename must be a valid identifier"),
1814            "got: {}",
1815            err.message
1816        );
1817    }
1818
1819    #[test]
1820    fn rejects_bundled_rename_with_non_string_value() {
1821        let f = write_tmp("tools:\n  - bundled: cypher_query\n    rename: 42\n");
1822        let err = load(f.path()).unwrap_err();
1823        assert!(
1824            err.message.contains("rename must be a string"),
1825            "got: {}",
1826            err.message
1827        );
1828    }
1829
1830    #[test]
1831    fn bundled_rename_serialises_to_json() {
1832        let f = write_tmp("tools:\n  - bundled: cypher_query\n    rename: legal_cypher_query\n");
1833        let m = load(f.path()).unwrap();
1834        let json = m.to_json();
1835        let tools = json.get("tools").and_then(|t| t.as_array()).unwrap();
1836        let entry = &tools[0];
1837        assert_eq!(entry.get("kind").and_then(|v| v.as_str()), Some("bundled"));
1838        assert_eq!(
1839            entry.get("name").and_then(|v| v.as_str()),
1840            Some("cypher_query")
1841        );
1842        assert_eq!(
1843            entry.get("rename").and_then(|v| v.as_str()),
1844            Some("legal_cypher_query")
1845        );
1846    }
1847
1848    #[test]
1849    fn bundled_override_to_json_shape() {
1850        let f = write_tmp(
1851            "tools:\n  - bundled: repo_management\n    description: \"FIRST STEP\"\n    hidden: false\n",
1852        );
1853        let m = load(f.path()).unwrap();
1854        let v = m.to_json();
1855        assert_eq!(v["tools"][0]["kind"], "bundled");
1856        assert_eq!(v["tools"][0]["name"], "repo_management");
1857        assert_eq!(v["tools"][0]["description"], "FIRST STEP");
1858        assert_eq!(v["tools"][0]["hidden"], false);
1859    }
1860
1861    #[test]
1862    fn embedder_parses() {
1863        let f = write_tmp(
1864            "embedder:\n  module: ./e.py\n  class: GraphEmbedder\n  kwargs:\n    cooldown: 900\n",
1865        );
1866        let m = load(f.path()).unwrap();
1867        let e = m.embedder.unwrap();
1868        assert_eq!(e.module, "./e.py");
1869        assert_eq!(e.class, "GraphEmbedder");
1870        assert_eq!(e.kwargs.get("cooldown").unwrap().as_i64(), Some(900));
1871    }
1872
1873    #[test]
1874    fn builtins_parses_temp_cleanup() {
1875        let f = write_tmp("builtins:\n  save_graph: true\n  temp_cleanup: on_overview\n");
1876        let m = load(f.path()).unwrap();
1877        assert!(m.builtins.save_graph);
1878        assert_eq!(m.builtins.temp_cleanup, TempCleanup::OnOverview);
1879    }
1880
1881    #[test]
1882    fn rejects_invalid_temp_cleanup() {
1883        let f = write_tmp("builtins:\n  temp_cleanup: nuke\n");
1884        assert!(load(f.path()).unwrap_err().message.contains("temp_cleanup"));
1885    }
1886
1887    #[test]
1888    fn allow_embedder_trust_parses() {
1889        let f = write_tmp("trust:\n  allow_embedder: true\n");
1890        let m = load(f.path()).unwrap();
1891        assert!(m.trust.allow_embedder);
1892    }
1893
1894    #[test]
1895    fn retired_allow_query_preprocessor_is_rejected_as_unknown() {
1896        // Retired in 0.3.43: the gate's sole consumer (kglite) removed the
1897        // preprocessor extension, so the strict validator now treats the key
1898        // as any other unknown trust key rather than carrying dead surface.
1899        let f = write_tmp("trust:\n  allow_query_preprocessor: true\n");
1900        let err = load(f.path()).unwrap_err();
1901        assert!(err.message.contains("trust keys"));
1902        assert!(err.message.contains("allow_query_preprocessor"));
1903    }
1904
1905    #[test]
1906    fn find_sibling_works() {
1907        let dir = tempfile::tempdir().unwrap();
1908        let graph = dir.path().join("demo.kgl");
1909        std::fs::write(&graph, b"\x00").unwrap();
1910        let sibling = dir.path().join("demo_mcp.yaml");
1911        std::fs::write(&sibling, "name: x\n").unwrap();
1912        assert_eq!(find_sibling_manifest(&graph), Some(sibling));
1913    }
1914
1915    #[test]
1916    fn workspace_local_parses() {
1917        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n  watch: true\n");
1918        let m = load(f.path()).unwrap();
1919        let w = m.workspace.unwrap();
1920        assert_eq!(w.kind, WorkspaceKind::Local);
1921        assert_eq!(w.root.as_deref(), Some("./src"));
1922        assert!(w.watch);
1923    }
1924
1925    #[test]
1926    fn workspace_github_default_kind() {
1927        let f = write_tmp("workspace: {}\n");
1928        let m = load(f.path()).unwrap();
1929        let w = m.workspace.unwrap();
1930        assert_eq!(w.kind, WorkspaceKind::Github);
1931        assert!(w.root.is_none());
1932        assert!(!w.watch);
1933    }
1934
1935    #[test]
1936    fn workspace_local_without_root_errors() {
1937        let f = write_tmp("workspace:\n  kind: local\n");
1938        let err = load(f.path()).unwrap_err();
1939        assert!(err.message.contains("requires workspace.root"));
1940    }
1941
1942    #[test]
1943    fn workspace_unknown_key_rejected() {
1944        let f = write_tmp("workspace:\n  kind: local\n  root: ./x\n  bogus: 1\n");
1945        let err = load(f.path()).unwrap_err();
1946        assert!(err.message.contains("unknown workspace keys"));
1947    }
1948
1949    #[test]
1950    fn workspace_invalid_kind_rejected() {
1951        let f = write_tmp("workspace:\n  kind: docker\n  root: ./x\n");
1952        let err = load(f.path()).unwrap_err();
1953        assert!(err.message.contains("workspace.kind"));
1954    }
1955
1956    #[test]
1957    fn workspace_watch_invalid_for_github() {
1958        let f = write_tmp("workspace:\n  kind: github\n  watch: true\n");
1959        let err = load(f.path()).unwrap_err();
1960        assert!(err.message.contains("watch is only valid"));
1961    }
1962
1963    #[test]
1964    fn workspace_sandbox_root_parses_for_local() {
1965        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n  sandbox_root: ./\n");
1966        let m = load(f.path()).unwrap();
1967        let w = m.workspace.unwrap();
1968        assert_eq!(w.sandbox_root.as_deref(), Some("./"));
1969    }
1970
1971    #[test]
1972    fn workspace_sandbox_root_absent_by_default() {
1973        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n");
1974        let m = load(f.path()).unwrap();
1975        assert!(m.workspace.unwrap().sandbox_root.is_none());
1976    }
1977
1978    #[test]
1979    fn workspace_sandbox_root_invalid_for_github() {
1980        let f = write_tmp("workspace:\n  kind: github\n  sandbox_root: ./repos\n");
1981        let err = load(f.path()).unwrap_err();
1982        assert!(
1983            err.message.contains("sandbox_root is only valid"),
1984            "unexpected error: {}",
1985            err.message
1986        );
1987    }
1988
1989    #[test]
1990    fn workspace_sandbox_root_must_be_a_non_empty_string() {
1991        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n  sandbox_root: 7\n");
1992        let err = load(f.path()).unwrap_err();
1993        assert!(
1994            err.message
1995                .contains("sandbox_root must be a non-empty string"),
1996            "unexpected error: {}",
1997            err.message
1998        );
1999        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n  sandbox_root: ''\n");
2000        let err = load(f.path()).unwrap_err();
2001        assert!(
2002            err.message
2003                .contains("sandbox_root must be a non-empty string"),
2004            "unexpected error: {}",
2005            err.message
2006        );
2007    }
2008
2009    #[test]
2010    fn workspace_adopt_client_roots_absent_by_default() {
2011        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n");
2012        let m = load(f.path()).unwrap();
2013        assert!(!m.workspace.unwrap().adopt_client_roots);
2014    }
2015
2016    #[test]
2017    fn workspace_adopt_client_roots_permits_omitting_root() {
2018        let f = write_tmp("workspace:\n  kind: local\n  adopt_client_roots: true\n");
2019        let m = load(f.path()).unwrap();
2020        let w = m.workspace.unwrap();
2021        assert!(w.adopt_client_roots);
2022        assert!(
2023            w.root.is_none(),
2024            "the root is expected to arrive from the client"
2025        );
2026    }
2027
2028    #[test]
2029    fn workspace_adopt_client_roots_false_still_requires_root() {
2030        // The relaxation is tied to the knob being *on*, not merely
2031        // present — a forgotten root must keep failing at boot.
2032        let f = write_tmp("workspace:\n  kind: local\n  adopt_client_roots: false\n");
2033        let err = load(f.path()).unwrap_err();
2034        assert!(err.message.contains("requires workspace.root"));
2035    }
2036
2037    #[test]
2038    fn workspace_adopt_client_roots_coexists_with_an_explicit_root() {
2039        let f = write_tmp(
2040            "workspace:\n  kind: local\n  root: ./src\n  sandbox_root: ./\n  adopt_client_roots: true\n",
2041        );
2042        let w = load(f.path()).unwrap().workspace.unwrap();
2043        assert!(w.adopt_client_roots);
2044        assert_eq!(w.root.as_deref(), Some("./src"));
2045    }
2046
2047    /// The documented rule ("`watch` requires `root`") is enforced by the
2048    /// loader, not only by `mcp-server`'s mode resolution — a library
2049    /// consumer that loads a manifest and builds its own workspace must
2050    /// not end up with a watcher that silently watches nothing.
2051    #[test]
2052    fn workspace_watch_requires_a_root_even_with_adoption_enabled() {
2053        let f = write_tmp("workspace:\n  kind: local\n  watch: true\n  adopt_client_roots: true\n");
2054        let err = load(f.path()).unwrap_err();
2055        assert!(
2056            err.message
2057                .contains("workspace.watch requires workspace.root"),
2058            "unexpected error: {}",
2059            err.message
2060        );
2061    }
2062
2063    #[test]
2064    fn workspace_watch_with_a_root_is_fine() {
2065        let f = write_tmp("workspace:\n  kind: local\n  root: ./src\n  watch: true\n");
2066        let w = load(f.path()).unwrap().workspace.unwrap();
2067        assert!(w.watch && w.root.is_some());
2068    }
2069
2070    #[test]
2071    fn workspace_adopt_client_roots_invalid_for_github() {
2072        let f = write_tmp("workspace:\n  kind: github\n  adopt_client_roots: true\n");
2073        let err = load(f.path()).unwrap_err();
2074        assert!(
2075            err.message.contains("adopt_client_roots is only valid"),
2076            "unexpected error: {}",
2077            err.message
2078        );
2079    }
2080
2081    #[test]
2082    fn workspace_adopt_client_roots_must_be_a_bool() {
2083        let f = write_tmp(
2084            "workspace:\n  kind: local\n  root: ./src\n  adopt_client_roots: yes-please\n",
2085        );
2086        let err = load(f.path()).unwrap_err();
2087        assert!(
2088            err.message.contains("adopt_client_roots must be a bool"),
2089            "unexpected error: {}",
2090            err.message
2091        );
2092    }
2093
2094    #[test]
2095    fn extensions_passthrough_parses() {
2096        let f = write_tmp(
2097            "extensions:\n  csv_http_server: true\n  csv_http_server_dir: temp/\n  arbitrary:\n    nested: 1\n",
2098        );
2099        let m = load(f.path()).unwrap();
2100        assert_eq!(
2101            m.extensions
2102                .get("csv_http_server")
2103                .and_then(|v| v.as_bool()),
2104            Some(true)
2105        );
2106        assert_eq!(
2107            m.extensions
2108                .get("csv_http_server_dir")
2109                .and_then(|v| v.as_str()),
2110            Some("temp/")
2111        );
2112        // Nested values pass through unchanged.
2113        assert_eq!(
2114            m.extensions
2115                .get("arbitrary")
2116                .and_then(|v| v.get("nested"))
2117                .and_then(|v| v.as_i64()),
2118            Some(1)
2119        );
2120    }
2121
2122    #[test]
2123    fn extensions_absent_defaults_to_empty() {
2124        let f = write_tmp("name: x\n");
2125        let m = load(f.path()).unwrap();
2126        assert!(m.extensions.is_empty());
2127    }
2128
2129    #[test]
2130    fn extensions_inner_keys_unvalidated() {
2131        // The framework intentionally does NOT validate keys inside
2132        // `extensions:` — they're downstream-binary concerns. Any shape
2133        // that's a YAML mapping must round-trip.
2134        let f = write_tmp(
2135            "extensions:\n  whatever_kglite_wants: foo\n  some_other_consumer: { a: 1, b: 2 }\n",
2136        );
2137        load(f.path()).unwrap();
2138    }
2139
2140    #[test]
2141    fn extensions_must_be_a_mapping() {
2142        let f = write_tmp("extensions: not-a-mapping\n");
2143        let err = load(f.path()).unwrap_err();
2144        assert!(err.message.contains("extensions must be a mapping"));
2145    }
2146
2147    #[test]
2148    fn env_file_key_parses() {
2149        let f = write_tmp("env_file: ../.env\n");
2150        let m = load(f.path()).unwrap();
2151        assert_eq!(m.env_file.as_deref(), Some("../.env"));
2152    }
2153
2154    #[test]
2155    fn env_file_unset_is_none() {
2156        let f = write_tmp("name: Demo\n");
2157        let m = load(f.path()).unwrap();
2158        assert!(m.env_file.is_none());
2159    }
2160
2161    #[test]
2162    fn find_workspace_works() {
2163        let dir = tempfile::tempdir().unwrap();
2164        let manifest = dir.path().join("workspace_mcp.yaml");
2165        std::fs::write(&manifest, "name: ws\n").unwrap();
2166        assert_eq!(find_workspace_manifest(dir.path()), Some(manifest));
2167    }
2168
2169    #[test]
2170    fn find_workspace_walks_one_level_up_with_applies_to() {
2171        // Layout: <tmp>/parent/workspace_mcp.yaml (declares
2172        // workspace.applies_to: ./repos) + <tmp>/parent/repos/.
2173        // Discovery from <tmp>/parent/repos/ should walk up one level
2174        // and find the sibling manifest because applies_to matches.
2175        let dir = tempfile::tempdir().unwrap();
2176        let parent = dir.path().join("parent");
2177        std::fs::create_dir(&parent).unwrap();
2178        let manifest = parent.join("workspace_mcp.yaml");
2179        std::fs::write(
2180            &manifest,
2181            "workspace:\n  kind: github\n  applies_to: ./repos\n",
2182        )
2183        .unwrap();
2184        let repos = parent.join("repos");
2185        std::fs::create_dir(&repos).unwrap();
2186
2187        // Primary location still works.
2188        assert_eq!(find_workspace_manifest(&parent), Some(manifest.clone()));
2189
2190        // Parent-walk fallback resolves to the same manifest. Compare
2191        // canonicalised paths to handle macOS /private/var vs /var.
2192        let found = find_workspace_manifest(&repos).expect("parent fallback should fire");
2193        assert_eq!(
2194            found.canonicalize().unwrap(),
2195            manifest.canonicalize().unwrap()
2196        );
2197    }
2198
2199    #[test]
2200    fn find_workspace_ignores_parent_without_applies_to() {
2201        // Parent manifest exists but does NOT declare workspace.applies_to.
2202        // The parent-walk fallback must refuse to auto-detect it —
2203        // otherwise an unrelated workspace_mcp.yaml in a sibling dir
2204        // could surprise-attach to whatever --workspace path the
2205        // operator passes. Safe default: require the opt-in.
2206        let dir = tempfile::tempdir().unwrap();
2207        let parent = dir.path().join("parent");
2208        std::fs::create_dir(&parent).unwrap();
2209        let manifest = parent.join("workspace_mcp.yaml");
2210        std::fs::write(&manifest, "name: not for repos\n").unwrap();
2211        let repos = parent.join("repos");
2212        std::fs::create_dir(&repos).unwrap();
2213
2214        assert_eq!(
2215            find_workspace_manifest(&repos),
2216            None,
2217            "parent manifest without workspace.applies_to must NOT auto-attach"
2218        );
2219    }
2220
2221    #[test]
2222    fn find_workspace_ignores_parent_with_mismatched_applies_to() {
2223        // Parent manifest declares applies_to: ./repos but the
2224        // actual --workspace path is ./other_dir. The mismatch must
2225        // suppress auto-detection.
2226        let dir = tempfile::tempdir().unwrap();
2227        let parent = dir.path().join("parent");
2228        std::fs::create_dir(&parent).unwrap();
2229        let manifest = parent.join("workspace_mcp.yaml");
2230        std::fs::write(
2231            &manifest,
2232            "workspace:\n  kind: github\n  applies_to: ./repos\n",
2233        )
2234        .unwrap();
2235        let other = parent.join("other_dir");
2236        std::fs::create_dir(&other).unwrap();
2237
2238        assert_eq!(
2239            find_workspace_manifest(&other),
2240            None,
2241            "applies_to: ./repos must NOT match --workspace ./other_dir"
2242        );
2243    }
2244
2245    #[test]
2246    fn find_workspace_applies_to_wildcard_matches_any_child() {
2247        // applies_to: '*' (or './*') means "any direct child of the
2248        // manifest's parent dir." Three different child names should
2249        // all auto-detect the manifest.
2250        let dir = tempfile::tempdir().unwrap();
2251        let parent = dir.path().join("parent");
2252        std::fs::create_dir(&parent).unwrap();
2253        let manifest = parent.join("workspace_mcp.yaml");
2254        std::fs::write(&manifest, "workspace:\n  kind: github\n  applies_to: '*'\n").unwrap();
2255        for child_name in ["repos", "clones", "totally-different-name"] {
2256            let child = parent.join(child_name);
2257            std::fs::create_dir(&child).unwrap();
2258            let found =
2259                find_workspace_manifest(&child).expect("wildcard should match any direct child");
2260            assert_eq!(
2261                found.canonicalize().unwrap(),
2262                manifest.canonicalize().unwrap(),
2263                "wildcard should match child {child_name:?}"
2264            );
2265        }
2266    }
2267
2268    #[test]
2269    fn find_workspace_applies_to_glob_matches_prefix() {
2270        // applies_to: './prod-*' should match any direct child whose
2271        // basename starts with "prod-".
2272        let dir = tempfile::tempdir().unwrap();
2273        let parent = dir.path().join("parent");
2274        std::fs::create_dir(&parent).unwrap();
2275        let manifest = parent.join("workspace_mcp.yaml");
2276        std::fs::write(
2277            &manifest,
2278            "workspace:\n  kind: github\n  applies_to: ./prod-*\n",
2279        )
2280        .unwrap();
2281        // Match cases.
2282        for child_name in ["prod-api", "prod-web", "prod-"] {
2283            let child = parent.join(child_name);
2284            std::fs::create_dir(&child).unwrap();
2285            assert!(
2286                find_workspace_manifest(&child).is_some(),
2287                "prod-* should match {child_name:?}"
2288            );
2289        }
2290        // Non-match cases.
2291        for child_name in ["test-api", "stage-web", "random"] {
2292            let child = parent.join(child_name);
2293            std::fs::create_dir(&child).unwrap();
2294            assert_eq!(
2295                find_workspace_manifest(&child),
2296                None,
2297                "prod-* should NOT match {child_name:?}"
2298            );
2299        }
2300    }
2301
2302    #[test]
2303    fn find_workspace_applies_to_list_matches_any_entry() {
2304        // applies_to: [./repos, ./clones] should match either name
2305        // but reject anything else.
2306        let dir = tempfile::tempdir().unwrap();
2307        let parent = dir.path().join("parent");
2308        std::fs::create_dir(&parent).unwrap();
2309        let manifest = parent.join("workspace_mcp.yaml");
2310        std::fs::write(
2311            &manifest,
2312            "workspace:\n  kind: github\n  applies_to:\n    - ./repos\n    - ./clones\n",
2313        )
2314        .unwrap();
2315        for matching in ["repos", "clones"] {
2316            let child = parent.join(matching);
2317            std::fs::create_dir(&child).unwrap();
2318            assert!(
2319                find_workspace_manifest(&child).is_some(),
2320                "list should match {matching:?}"
2321            );
2322        }
2323        let other = parent.join("scratch");
2324        std::fs::create_dir(&other).unwrap();
2325        assert_eq!(
2326            find_workspace_manifest(&other),
2327            None,
2328            "list with [repos, clones] must NOT match scratch"
2329        );
2330    }
2331
2332    #[test]
2333    fn applies_to_rejects_deep_path_at_parse_time() {
2334        let f = write_tmp("workspace:\n  kind: github\n  applies_to: ./too/deep/path\n");
2335        let err = load(f.path()).unwrap_err();
2336        assert!(
2337            err.message.contains("must be a single path segment"),
2338            "got: {}",
2339            err.message
2340        );
2341    }
2342
2343    #[test]
2344    fn applies_to_rejects_invalid_glob_at_parse_time() {
2345        // globset rejects unterminated character class.
2346        let f = write_tmp("workspace:\n  kind: github\n  applies_to: './[unterminated'\n");
2347        let err = load(f.path()).unwrap_err();
2348        assert!(
2349            err.message.contains("invalid glob pattern"),
2350            "got: {}",
2351            err.message
2352        );
2353    }
2354
2355    #[test]
2356    fn applies_to_rejects_parent_relative() {
2357        // Bare `..` is caught by the `..` rejection branch. The
2358        // multi-segment form `../foo` is caught earlier by the
2359        // single-segment check; either is rejected.
2360        let f = write_tmp("workspace:\n  kind: github\n  applies_to: '..'\n");
2361        let err = load(f.path()).unwrap_err();
2362        assert!(err.message.contains("must not contain `..`"));
2363
2364        let f2 = write_tmp("workspace:\n  kind: github\n  applies_to: '../up'\n");
2365        let err2 = load(f2.path()).unwrap_err();
2366        assert!(err2.message.contains("must be a single path segment"));
2367    }
2368
2369    #[test]
2370    fn find_workspace_returns_none_when_missing_everywhere() {
2371        let dir = tempfile::tempdir().unwrap();
2372        let child = dir.path().join("child");
2373        std::fs::create_dir(&child).unwrap();
2374        // No manifest in either child or its parent (tmpdir root).
2375        assert_eq!(find_workspace_manifest(&child), None);
2376    }
2377
2378    #[test]
2379    fn find_workspace_primary_wins_over_parent_fallback() {
2380        // Both primary AND parent-fallback exist. The primary must
2381        // win — this anchors the precedence rule documented on
2382        // `find_workspace_manifest`. The parent declares applies_to
2383        // matching the child dir, so it WOULD be a valid fallback —
2384        // but the primary preempts it. If a future refactor swaps
2385        // the order, this test fails loudly.
2386        let dir = tempfile::tempdir().unwrap();
2387        let parent_manifest = dir.path().join("workspace_mcp.yaml");
2388        std::fs::write(
2389            &parent_manifest,
2390            "workspace:\n  kind: github\n  applies_to: ./repos\n",
2391        )
2392        .unwrap();
2393        let child = dir.path().join("repos");
2394        std::fs::create_dir(&child).unwrap();
2395        let child_manifest = child.join("workspace_mcp.yaml");
2396        std::fs::write(&child_manifest, "name: child\n").unwrap();
2397
2398        // Discovery from `child` should return the child manifest,
2399        // NOT the parent's. Compare canonicalised to handle the
2400        // macOS /private/var vs /var symlink consistently.
2401        let found = find_workspace_manifest(&child).expect("primary should resolve");
2402        assert_eq!(
2403            found.canonicalize().unwrap(),
2404            child_manifest.canonicalize().unwrap(),
2405            "primary location must win when both primary and parent fallback exist"
2406        );
2407    }
2408
2409    #[test]
2410    fn to_json_shape_is_stable() {
2411        let f = write_tmp(
2412            r#"
2413name: KGLite Codebase
2414source_roots: [src, lib]
2415trust:
2416  allow_embedder: true
2417embedder:
2418  module: kglite.embed
2419  class: SentenceTransformerEmbedder
2420builtins:
2421  save_graph: true
2422  temp_cleanup: on_overview
2423"#,
2424        );
2425        let m = load(f.path()).unwrap();
2426        let actual = m.to_json();
2427        let expected = serde_json::json!({
2428            "yaml_path": f.path().display().to_string(),
2429            "name": "KGLite Codebase",
2430            "instructions": null,
2431            "overview_prefix": null,
2432            "source_roots": ["src", "lib"],
2433            "trust": {
2434                "allow_python_tools": false,
2435                "allow_embedder": true,
2436            },
2437            "tools": [],
2438            "embedder": {
2439                "module": "kglite.embed",
2440                "class": "SentenceTransformerEmbedder",
2441                "kwargs": {},
2442            },
2443            "builtins": { "save_graph": true, "temp_cleanup": "on_overview", "screen_stargazers": true },
2444            "env_file": null,
2445            "workspace": null,
2446            "extensions": {},
2447            "skills": false,
2448        });
2449        assert_eq!(actual, expected);
2450    }
2451
2452    #[test]
2453    fn to_json_round_trips_tools_and_workspace() {
2454        let f = write_tmp(
2455            r#"
2456name: Full Surface
2457source_root: ./src
2458trust:
2459  allow_python_tools: true
2460tools:
2461  - name: nodes_for
2462    cypher: "MATCH (n {name: $name}) RETURN n"
2463    description: "fetch nodes by name"
2464  - name: run_query
2465    python: tools.py
2466    function: run
2467workspace:
2468  kind: local
2469  root: /tmp/ws
2470  watch: true
2471builtins:
2472  save_graph: false
2473env_file: .env.local
2474extensions:
2475  kglite:
2476    flavour: standard
2477"#,
2478        );
2479        let m = load(f.path()).unwrap();
2480        let v = m.to_json();
2481        assert_eq!(v["name"], "Full Surface");
2482        assert_eq!(v["trust"]["allow_python_tools"], true);
2483        assert_eq!(v["workspace"]["kind"], "local");
2484        assert_eq!(v["workspace"]["root"], "/tmp/ws");
2485        assert_eq!(v["workspace"]["watch"], true);
2486        assert_eq!(v["env_file"], ".env.local");
2487        assert_eq!(v["tools"][0]["kind"], "cypher");
2488        assert_eq!(v["tools"][0]["name"], "nodes_for");
2489        assert_eq!(v["tools"][1]["kind"], "python");
2490        assert_eq!(v["tools"][1]["name"], "run_query");
2491        assert_eq!(v["tools"][1]["python"], "tools.py");
2492        assert_eq!(v["tools"][1]["function"], "run");
2493        assert_eq!(v["extensions"]["kglite"]["flavour"], "standard");
2494    }
2495
2496    // ─── Skills schema (Phase 1a — manifest-level only) ───────────
2497
2498    #[test]
2499    fn skills_disabled_by_default() {
2500        let f = write_tmp("name: x\n");
2501        let m = load(f.path()).unwrap();
2502        assert_eq!(m.skills, SkillsSource::Disabled);
2503        assert_eq!(m.to_json()["skills"], serde_json::Value::Bool(false));
2504    }
2505
2506    #[test]
2507    fn skills_explicit_false_disabled() {
2508        let f = write_tmp("name: x\nskills: false\n");
2509        let m = load(f.path()).unwrap();
2510        assert_eq!(m.skills, SkillsSource::Disabled);
2511    }
2512
2513    #[test]
2514    fn skills_bool_true_parses_to_single_bundled() {
2515        let f = write_tmp("name: x\nskills: true\n");
2516        let m = load(f.path()).unwrap();
2517        assert_eq!(m.skills, SkillsSource::Sources(vec![SkillSource::Bundled]));
2518        // JSON shape: list with one boolean true.
2519        let v = m.to_json();
2520        assert_eq!(v["skills"], serde_json::json!([true]));
2521    }
2522
2523    #[test]
2524    fn skills_path_string_parses_to_single_path() {
2525        let f = write_tmp("name: x\nskills: ./local-skills/\n");
2526        let m = load(f.path()).unwrap();
2527        assert_eq!(
2528            m.skills,
2529            SkillsSource::Sources(vec![SkillSource::Path("./local-skills/".into())])
2530        );
2531        // JSON round-trip preserves the operator-declared path verbatim.
2532        let v = m.to_json();
2533        assert_eq!(v["skills"], serde_json::json!(["./local-skills/"]));
2534    }
2535
2536    #[test]
2537    fn skills_list_polymorphic_parses() {
2538        let f =
2539            write_tmp("name: x\nskills:\n  - true\n  - ./local-overrides/\n  - ~/shared-skills/\n");
2540        let m = load(f.path()).unwrap();
2541        assert_eq!(
2542            m.skills,
2543            SkillsSource::Sources(vec![
2544                SkillSource::Bundled,
2545                SkillSource::Path("./local-overrides/".into()),
2546                SkillSource::Path("~/shared-skills/".into()),
2547            ])
2548        );
2549        // JSON preserves entry types: bool for bundled, string for paths.
2550        let v = m.to_json();
2551        assert_eq!(
2552            v["skills"],
2553            serde_json::json!([true, "./local-overrides/", "~/shared-skills/"])
2554        );
2555    }
2556
2557    #[test]
2558    fn skills_empty_list_parses_as_opt_in_with_no_root_sources() {
2559        // Empty list means "opt in but only the auto-detected project
2560        // layer fires." The registry treats this as `Sources(vec![])`,
2561        // not `Disabled`. Operators relying solely on
2562        // `<basename>.skills/` adjacent to the YAML use this form.
2563        let f = write_tmp("name: x\nskills: []\n");
2564        let m = load(f.path()).unwrap();
2565        assert_eq!(m.skills, SkillsSource::Sources(vec![]));
2566    }
2567
2568    #[test]
2569    fn skills_false_in_list_rejected() {
2570        let f = write_tmp("name: x\nskills:\n  - false\n");
2571        let err = load(f.path()).unwrap_err();
2572        assert!(
2573            err.message.contains("skills[0]")
2574                && err.message.contains("`false` is not a valid entry"),
2575            "unexpected: {}",
2576            err.message
2577        );
2578    }
2579
2580    #[test]
2581    fn skills_invalid_type_rejected() {
2582        let f = write_tmp("name: x\nskills: 42\n");
2583        let err = load(f.path()).unwrap_err();
2584        assert!(
2585            err.message.contains("skills must be"),
2586            "unexpected: {}",
2587            err.message
2588        );
2589    }
2590
2591    #[test]
2592    fn skills_empty_path_string_rejected() {
2593        let f = write_tmp("name: x\nskills: \"\"\n");
2594        let err = load(f.path()).unwrap_err();
2595        assert!(
2596            err.message.contains("non-empty string"),
2597            "unexpected: {}",
2598            err.message
2599        );
2600    }
2601
2602    #[test]
2603    fn skills_field_is_purely_additive_on_existing_manifests() {
2604        // A manifest written before the skills field existed (i.e. no
2605        // `skills:` declaration) must still parse cleanly with
2606        // SkillsSource::Disabled. This is the "no impact on existing
2607        // MCP servers" guarantee at the schema level.
2608        let f = write_tmp(
2609            r#"
2610name: legacy
2611source_roots: [src]
2612trust:
2613  allow_python_tools: true
2614workspace:
2615  kind: github
2616"#,
2617        );
2618        let m = load(f.path()).unwrap();
2619        assert_eq!(m.skills, SkillsSource::Disabled);
2620        assert_eq!(m.to_json()["skills"], serde_json::Value::Bool(false));
2621    }
2622}