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