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