Skip to main content

mur_common/agent/
mcp.rs

1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
4pub struct McpServerEntry {
5    pub name: String,
6    pub command: String,
7    #[serde(default)]
8    pub args: Vec<String>,
9
10    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
11    /// path, captured at install time. `None` means the entry was
12    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
13    /// not applied — the supervisor will warn but not block.
14    /// (B0 rule 6 / M9.1)
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub binary_sha256: Option<String>,
17
18    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
19    /// `tools/list` response, captured at install time. `None` means
20    /// the install path skipped the description probe (e.g. the MCP
21    /// uses a non-stdio transport or the binary couldn't be reached)
22    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub description_hash: Option<String>,
25
26    /// Display-only publisher metadata captured at install time so
27    /// the user can recall what they consented to. `None` for older
28    /// entries. (B0 rule 6 / M9.1)
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub publisher: Option<McpPublisherInfo>,
31
32    /// RFC3339 timestamp of when the entry was added or last
33    /// re-approved by the user via `mur agent mcp pin`. Used by the
34    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
37
38    /// Per-tool-call timeout for this server, in seconds. `None` uses the
39    /// runtime default. Slow tools (e.g. `video_analyze`: transcript fetch
40    /// + local-model map-reduce) need a longer budget than the default.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub timeout_secs: Option<u32>,
43
44    /// Per-server outbound egress override. `None` = inherit the agent-level
45    /// policy (default; unchanged behavior). `Restricted` routes this server's
46    /// child through the runtime egress proxy with `allow_hosts` (advisory).
47    /// See `docs/superpowers/plans/2026-06-26-mcp-per-server-egress.md`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub network: Option<McpServerNetwork>,
50
51    /// HTTP(S) base URL for a remote (Streamable-HTTP or SSE) MCP server.
52    /// Mutually exclusive with `command` in practice; `None` = stdio transport.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub url: Option<String>,
55
56    /// Authentication credentials for a remote MCP server.
57    /// `None` = no auth (or stdio transport).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub auth: Option<McpAuth>,
60
61    /// External programs this artifact needs at runtime (portable-deps spec).
62    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub requires_programs: Vec<ProgramDep>,
65
66    /// Paths this server writes state into at runtime, declared at install
67    /// time so the sandbox can be told about them (issue #1161).
68    ///
69    /// Distinct from everything above: `command`, `args` and `package`
70    /// describe how the server is *launched*, and #1158 already syncs what the
71    /// rewritten launch line needs. These are what the server touches once it
72    /// is running — a property of the server, not of the command MUR rewrote.
73    /// `@wonderwhy-er/desktop-commander` wants three of them under `$HOME` and
74    /// exits 1 before answering `initialize` without them.
75    ///
76    /// Granted read+write, and **created if missing** at install time. The
77    /// sandbox drops entitlement paths that do not exist when the profile is
78    /// sealed, so granting a directory the server has not created yet would be
79    /// accepted and still denied by the kernel — see `reject_dead_grant`.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub state_paths: Vec<String>,
82
83    /// Vendored package this entry launches, when MUR installed it itself.
84    ///
85    /// Present only for entries moved off a package runner by
86    /// `mur agent mcp vendor`. Its existence is what makes the contents of an
87    /// interpreter-launched server verifiable at all: `npx @scope/pkg` resolves
88    /// on every spawn and pins nothing, whereas a vendored install lives in a
89    /// directory MUR owns and can be checked before the agent comes up.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub package: Option<McpPackagePin>,
92
93    /// Which known server this entry launches, when MUR must harden its launch.
94    ///
95    /// Absent for every ordinary entry, which then behaves exactly as before.
96    /// A typed kind (not a generic `env` field) is the one hook point for
97    /// server-specific preflight checks and runtime-computed environment, so
98    /// no profile can set arbitrary environment variables on an MCP child.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub kind: Option<McpServerKind>,
101
102    /// The one project directory this server serves. Only meaningful with a
103    /// `kind` that is project-scoped (serena); ignored otherwise.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub project: Option<std::path::PathBuf>,
106
107    /// Keys this build does not know (written by a newer `mur`). Kept so a
108    /// rewrite by an older binary round-trips them (#1713) — dropping a key
109    /// like `kind` would leave the entry less constrained than its author
110    /// intended. Never read by logic.
111    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
112    pub extra: BTreeMap<String, serde_yaml_ng::Value>,
113}
114
115/// A known MCP server that MUR launches with server-specific hardening.
116///
117/// Unknown values are a parse error, never silently ignored: a profile that
118/// asks for hardening MUR does not know must not start the server unhardened.
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum McpServerKind {
122    Serena,
123}
124
125/// A package MUR installed itself, and the fingerprint that proves the
126/// installed tree hasn't changed.
127///
128/// `lockfile_sha256` hashes the install's `package-lock.json`, which already
129/// records an integrity hash for every package in the dependency tree — so one
130/// small file covers the whole tree, and startup verification stays cheap no
131/// matter how large `node_modules` grows.
132///
133/// The lockfile pins what was *installed*. Editing a file inside
134/// `node_modules` afterwards would not change it; catching that needs a full
135/// tree hash, which is deliberately not done here — see the module docs on
136/// `mur-core::cmd::agent_mcp_vendor` for where that line is drawn.
137#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
138pub struct McpPackagePin {
139    /// Package ecosystem — `npm` today.
140    pub runner: String,
141    /// Package name, including any `@scope/` prefix.
142    pub name: String,
143    /// Exact installed version.
144    pub version: String,
145    /// Directory MUR installed into, absolute.
146    pub install_dir: String,
147    /// SHA-256 (lowercase hex) of `<install_dir>/package-lock.json`.
148    pub lockfile_sha256: String,
149
150    /// How many packages in the installed tree published no registry
151    /// signature, as reported by `npm audit signatures` at vendor time.
152    ///
153    /// `None` — the audit did not run (npm too old, or offline).
154    /// `Some(0)` — every package in the tree carried a verified signature.
155    /// `Some(n)` — `n` packages are unsigned; the rest verified.
156    ///
157    /// A signature that verifies proves the bytes came from the registry, which
158    /// the content hash cannot: it would faithfully pin a poisoned cache. An
159    /// *invalid* signature is not recorded here because it blocks the vendor
160    /// outright — that is an integrity failure, not a property to note.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub signatures_missing: Option<u32>,
163
164    /// SLSA predicate type of the package's build provenance, when it
165    /// publishes one — e.g. `https://slsa.dev/provenance/v1`. `None` means no
166    /// attestation was published (still the common case).
167    ///
168    /// Provenance ties a release back to a source repository and CI run, and
169    /// is the only signal here that can catch a **malicious publish**: a
170    /// content hash pins whatever was released, faithfully preserving a
171    /// poisoned version rather than detecting it. Recorded and shown, never
172    /// required — ecosystem coverage is far too thin to gate on.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub provenance: Option<String>,
175}
176
177impl McpPackagePin {
178    /// Name of the lockfile whose hash is `lockfile_sha256`.
179    ///
180    /// npm writes `package-lock.json` itself; for PyPI, MUR generates one with
181    /// `uv pip compile --generate-hashes`, which records a sha256 for every
182    /// package in the resolved tree — the same property that lets one small
183    /// file stand in for the whole install.
184    pub fn lockfile_name(&self) -> &'static str {
185        match self.runner.as_str() {
186            "pypi" => "requirements.lock",
187            _ => "package-lock.json",
188        }
189    }
190
191    /// Absolute path of the lockfile this pin covers.
192    ///
193    /// The startup check, `inspect`, and the deep audit all resolve it through
194    /// here, so a newly supported ecosystem cannot end up verified against the
195    /// wrong file in one of them and silently pass.
196    pub fn lockfile_path(&self) -> std::path::PathBuf {
197        std::path::Path::new(&self.install_dir).join(self.lockfile_name())
198    }
199}
200
201/// Authentication scheme for a remote (HTTP) MCP server.
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
203#[serde(rename_all = "snake_case", tag = "kind")]
204pub enum McpAuth {
205    /// Static bearer token stored as a secret reference.
206    Bearer { token: crate::secret::SecretRef },
207    /// OAuth 2.1 token, with dynamic client registration state.
208    Oauth(OauthAuth),
209}
210
211/// OAuth 2.1 state persisted alongside remote MCP entry.
212#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
213pub struct OauthAuth {
214    /// Authorization-server token endpoint (from discovery).
215    pub token_endpoint: String,
216    /// Client id from dynamic client registration.
217    pub client_id: String,
218    /// Keychain ref to access token.
219    pub access_token: crate::secret::SecretRef,
220    /// Keychain ref refresh token, if server issued one.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub refresh_token: Option<crate::secret::SecretRef>,
223    /// Unix-epoch seconds access token expires (0 = unknown).
224    #[serde(default)]
225    pub expires_at: u64,
226}
227
228/// How an MCP server's outbound network is scoped.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
230#[serde(rename_all = "snake_case")]
231pub enum McpNetMode {
232    /// No per-server policy and no proxy — the default.
233    ///
234    /// NOT "inherits `entitlements.network.outbound.allow_hosts`", despite the
235    /// name. That list is enforced in-process (a DNS guard on the runtime's own
236    /// HTTP client, plus the B0 gate on the agent's `network.*` tools), and a
237    /// spawned server never runs either. What a server here actually inherits
238    /// is the OS sandbox — which restricts by PORT, with the host left open.
239    ///
240    /// So an agent whose `allow_hosts` names one API still lets an `Inherit`
241    /// server reach any host on an allowed port. Use `Restricted` to bound a
242    /// server by host. The variant keeps its name because it is a serialized
243    /// wire value; the lie was the doc, and it is fixed here rather than
244    /// migrated.
245    #[default]
246    Inherit,
247    /// Allow only `allow_hosts`, routed through the runtime egress proxy.
248    Restricted,
249    /// Allow ALL hosts EXCEPT `deny_hosts`, routed through the runtime egress
250    /// proxy, with every CONNECT audited. For trusted-but-broad tools (e.g. a
251    /// web-research browser) that cannot enumerate their destinations. Requires
252    /// explicit operator consent (records `authorization`); downgraded to
253    /// `Inherit` on import (lowest trust). Advisory enforcement (see egress_proxy).
254    BroadAudited,
255    /// No outbound for this server at all.
256    Off,
257}
258
259/// Env var name a sandboxed MCP child reads to self-enforce the operator's
260/// `deny_hosts` overlay on connections the egress proxy cannot observe (e.g.
261/// `mur-research-gateway`'s tier-2/3 browser subprocesses — the proxy only
262/// sees tier-1 `reqwest` traffic). `mur-agent-runtime`'s `proxy_env_for` sets
263/// this on the child's env alongside the proxy vars; a cooperating child
264/// (currently `mur-research-gateway`, via `config::load`) reads it to source
265/// its own deny list. Single definition shared by both crates (CLAUDE.md
266/// rule 1: no duplicated literal).
267pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
268
269/// Per-MCP-server outbound egress policy.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
271pub struct McpServerNetwork {
272    #[serde(default)]
273    pub mode: McpNetMode,
274    #[serde(default)]
275    pub allow_hosts: Vec<String>,
276    /// Deny overlay for `BroadAudited` mode: hosts blocked even though all
277    /// others are allowed. Ignored by `Restricted`/`Inherit`/`Off`.
278    #[serde(default)]
279    pub deny_hosts: Vec<String>,
280    /// Who authorized a `BroadAudited` grant, and when. `None` for other modes.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub authorization: Option<EgressAuthorization>,
283}
284
285/// True when any of `entries` declares a scoped network policy
286/// (`Restricted` / `BroadAudited`) — i.e. it must be spawned behind the
287/// loopback egress proxy with a tokened `HTTPS_PROXY`.
288///
289/// The single source for that decision. The runtime calls it before the
290/// kernel sandbox seals (so the proxy port can be carved into the profile);
291/// `mur agent mcp add`'s live probe calls it to decide whether the probe child
292/// needs a proxy at all. Both must agree, or a server that passes the probe
293/// fails under the runtime (or vice versa) — which is why it lives here and
294/// not in either caller.
295pub fn entries_need_egress(entries: &[McpServerEntry]) -> bool {
296    entries.iter().any(|e| {
297        matches!(
298            e.network.as_ref().map(|n| n.mode),
299            Some(McpNetMode::Restricted) | Some(McpNetMode::BroadAudited)
300        )
301    })
302}
303
304/// A plugin-group imported by one agent (add-on Phase 2). Self-contained:
305/// members are installed PER-AGENT (skills under
306/// `~/.mur/agents/<a>/skills/`, mcp appended to this profile's
307/// `mcp_servers`). No global library, no refcounting.
308///
309/// Fail-closed: `enabled` defaults to `false`. Only an explicit user
310/// toggle (CLI/Hub) or a trusted native installer flips it true — the
311/// importer always constructs it `false`.
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
313pub struct AddonRef {
314    /// e.g. "superpowers" (local) or "superpowers@claude-plugins-official".
315    pub id: String,
316    /// Provenance, free-text. e.g. "claude-local:superpowers@6.0.3".
317    pub source: String,
318    #[serde(default)]
319    pub enabled: bool,
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub skills: Vec<String>,
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    pub mcp: Vec<String>,
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    pub commands: Vec<String>,
326    /// Content-hash pin over the imported skill/command manifests, recorded
327    /// at import. `None` on legacy refs. Enables drift detection + refresh.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub content_hash: Option<String>,
330    /// The re-fetchable source (the original `import` argument: a local path
331    /// or `owner/repo`), distinct from the free-text provenance `source`.
332    /// `None` on legacy refs. Used by `reimport`.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub fetch_ref: Option<String>,
335    /// The `--plugin <name>` selector used at import time to pick one plugin
336    /// out of a multi-plugin marketplace `fetch_ref`. `None` when the source
337    /// was a single-plugin dir/repo, or on legacy refs. Used by `reimport` so
338    /// a marketplace add-on can be re-fetched without re-specifying it.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub fetch_plugin: Option<String>,
341}
342
343/// Display-only publisher metadata captured at install time. None of
344/// the fields are validated against any external authority — they're
345/// shown to the user during the install confirm prompt and reproduced
346/// in `mur agent mcp inspect` output so the user can audit who they
347/// thought they were trusting. (B0 rule 6 / M9.1)
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
349pub struct McpPublisherInfo {
350    /// Free-form publisher identifier — e.g. `"Anthropic"`,
351    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
352    pub name: String,
353
354    /// Optional homepage / docs URL. Best-effort: extracted from the
355    /// MCP's `serverInfo.metadata.homepage` or registry entry when
356    /// available; otherwise left unset.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub homepage: Option<String>,
359
360    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
361    /// Used purely for display; not consumed by any verification path.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub registry_id: Option<String>,
364}
365
366#[cfg(test)]
367mod mcp_pin_tests {
368    use super::*;
369
370    /// Pre-M9 profiles must continue to deserialize with the new
371    /// optional fields absent. Round-trip: serialize back out and
372    /// confirm the optional fields don't leak into the YAML.
373    #[test]
374    fn pre_m9_entry_roundtrips_without_pin_fields() {
375        let yaml = r#"
376name: weather
377command: /opt/mcp/weather
378args: ["--port", "0"]
379"#;
380        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
381        assert_eq!(entry.name, "weather");
382        assert_eq!(entry.binary_sha256, None);
383        assert_eq!(entry.description_hash, None);
384        assert_eq!(entry.publisher, None);
385        assert_eq!(entry.installed_at, None);
386
387        // skip_serializing_if = "Option::is_none" must keep the YAML
388        // free of empty pin fields when the entry is pre-M9.
389        let out = serde_yaml_ng::to_string(&entry).unwrap();
390        assert!(!out.contains("binary_sha256"), "got {out}");
391        assert!(!out.contains("description_hash"), "got {out}");
392        assert!(!out.contains("publisher"), "got {out}");
393        assert!(!out.contains("installed_at"), "got {out}");
394    }
395
396    /// Full M9 entry with all fields set round-trips losslessly.
397    #[test]
398    fn full_m9_entry_roundtrips_all_fields() {
399        let yaml = r#"
400name: weather
401command: /opt/mcp/weather
402args: []
403binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
404description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
405publisher:
406  name: "@anthropic-mcp/weather"
407  homepage: "https://github.com/anthropic-mcp/weather"
408  registry_id: "@anthropic-mcp/weather@1.2.3"
409installed_at: "2026-05-06T08:00:00Z"
410"#;
411        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
412        assert!(
413            entry
414                .binary_sha256
415                .as_deref()
416                .unwrap()
417                .starts_with("3f4abca8")
418        );
419        assert!(
420            entry
421                .description_hash
422                .as_deref()
423                .unwrap()
424                .starts_with("9a01b2c3")
425        );
426        let pub_info = entry.publisher.clone().unwrap();
427        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
428        assert_eq!(
429            pub_info.homepage.as_deref(),
430            Some("https://github.com/anthropic-mcp/weather"),
431        );
432        assert_eq!(
433            pub_info.registry_id.as_deref(),
434            Some("@anthropic-mcp/weather@1.2.3"),
435        );
436        let installed = entry.installed_at.unwrap();
437        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
438    }
439
440    /// Partial — only the binary hash is set (e.g. probe failed but
441    /// install proceeded). The supervisor still needs to be able to
442    /// deserialize this without panicking.
443    #[test]
444    fn partial_pin_only_binary_sha_roundtrips() {
445        let yaml = r#"
446name: weather
447command: /opt/mcp/weather
448args: []
449binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
450"#;
451        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
452        assert_eq!(
453            entry.binary_sha256.as_deref(),
454            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
455        );
456        assert_eq!(entry.description_hash, None);
457        assert_eq!(entry.publisher, None);
458    }
459
460    /// Publisher with only the required `name` field — homepage and
461    /// registry_id are optional.
462    #[test]
463    fn publisher_minimal_just_name() {
464        let yaml = r#"
465name: weather
466command: /opt/mcp/weather
467args: []
468publisher:
469  name: "alice"
470"#;
471        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
472        let p = entry.publisher.as_ref().unwrap();
473        assert_eq!(p.name, "alice");
474        assert_eq!(p.homepage, None);
475        assert_eq!(p.registry_id, None);
476
477        // skip_serializing_if must omit the optional sub-fields too.
478        let out = serde_yaml_ng::to_string(&entry).unwrap();
479        assert!(!out.contains("homepage:"), "got {out}");
480        assert!(!out.contains("registry_id:"), "got {out}");
481    }
482}
483
484#[cfg(test)]
485mod remote_mcp_tests {
486    use super::*;
487
488    #[test]
489    fn mcp_entry_roundtrips_remote_bearer() {
490        let e = McpServerEntry {
491            name: "gh".into(),
492            command: String::new(),
493            url: Some("https://api.example.com/mcp".into()),
494            auth: Some(McpAuth::Bearer {
495                token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
496            }),
497            ..Default::default()
498        };
499        let y = serde_yaml_ng::to_string(&e).unwrap();
500        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
501        assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
502        assert!(matches!(
503            back.auth,
504            Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
505        ));
506        // A legacy stdio entry (no url/auth) still parses.
507        let legacy: McpServerEntry =
508            serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
509        assert!(legacy.url.is_none());
510        assert!(legacy.auth.is_none());
511    }
512}
513
514#[cfg(test)]
515mod requires_programs_tests {
516    #[test]
517    fn mcp_entry_parses_requires_programs_and_defaults_empty() {
518        let with = r#"
519name: research-gateway
520command: mur-research-gateway
521requires_programs:
522  - name: lightpanda
523    detect: { file: "~/.mur/aura/lightpanda" }
524    reason: "render tier"
525    registry: lightpanda
526"#;
527        let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
528        assert_eq!(e.requires_programs.len(), 1);
529        assert_eq!(e.requires_programs[0].name, "lightpanda");
530
531        // Absent block → empty (back-compat).
532        let without = "name: x\ncommand: y\n";
533        let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
534        assert!(e2.requires_programs.is_empty());
535    }
536}
537
538#[cfg(test)]
539mod egress_need_tests {
540    #[test]
541    fn entries_need_egress_matches_scoped_modes() {
542        use super::*;
543        fn entry(mode: Option<McpNetMode>) -> McpServerEntry {
544            let mut e = McpServerEntry {
545                name: "s".into(),
546                command: "cmd".into(),
547                ..Default::default()
548            };
549            e.network = mode.map(|m| McpServerNetwork {
550                mode: m,
551                ..Default::default()
552            });
553            e
554        }
555        assert!(!entries_need_egress(&[entry(None)]));
556        assert!(!entries_need_egress(&[entry(Some(McpNetMode::Inherit))]));
557        assert!(!entries_need_egress(&[entry(Some(McpNetMode::Off))]));
558        assert!(entries_need_egress(&[entry(Some(McpNetMode::Restricted))]));
559        assert!(entries_need_egress(&[
560            entry(None),
561            entry(Some(McpNetMode::BroadAudited))
562        ]));
563    }
564}
565
566#[cfg(test)]
567mod server_kind_tests {
568    use super::*;
569
570    const LEGACY: &str = "name: fs\ncommand: npx\nargs:\n- -y\n- fs\n";
571
572    #[test]
573    fn absent_kind_and_project_serialize_byte_identical() {
574        let e: McpServerEntry = serde_yaml_ng::from_str(LEGACY).unwrap();
575        assert!(e.kind.is_none());
576        assert!(e.project.is_none());
577        assert_eq!(serde_yaml_ng::to_string(&e).unwrap(), LEGACY);
578    }
579
580    #[test]
581    fn serena_kind_and_project_roundtrip() {
582        let e = McpServerEntry {
583            name: "serena".into(),
584            command: "serena".into(),
585            kind: Some(McpServerKind::Serena),
586            project: Some("/work/repo".into()),
587            ..Default::default()
588        };
589        let y = serde_yaml_ng::to_string(&e).unwrap();
590        assert!(y.contains("kind: serena"), "{y}");
591        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
592        assert_eq!(back, e);
593    }
594
595    #[test]
596    fn unknown_kind_is_a_parse_error() {
597        let y = format!("{LEGACY}kind: not_a_server\n");
598        let err = serde_yaml_ng::from_str::<McpServerEntry>(&y).unwrap_err();
599        assert!(err.to_string().contains("kind"), "{err}");
600    }
601}