use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct McpServerEntry {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<McpPublisherInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<McpServerNetwork>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<McpAuth>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires_programs: Vec<ProgramDep>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub state_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<McpPackagePin>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
pub struct McpPackagePin {
pub runner: String,
pub name: String,
pub version: String,
pub install_dir: String,
pub lockfile_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signatures_missing: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
}
impl McpPackagePin {
pub fn lockfile_name(&self) -> &'static str {
match self.runner.as_str() {
"pypi" => "requirements.lock",
_ => "package-lock.json",
}
}
pub fn lockfile_path(&self) -> std::path::PathBuf {
std::path::Path::new(&self.install_dir).join(self.lockfile_name())
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum McpAuth {
Bearer { token: crate::secret::SecretRef },
Oauth(OauthAuth),
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct OauthAuth {
pub token_endpoint: String,
pub client_id: String,
pub access_token: crate::secret::SecretRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<crate::secret::SecretRef>,
#[serde(default)]
pub expires_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum McpNetMode {
#[default]
Inherit,
Restricted,
BroadAudited,
Off,
}
pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct McpServerNetwork {
#[serde(default)]
pub mode: McpNetMode,
#[serde(default)]
pub allow_hosts: Vec<String>,
#[serde(default)]
pub deny_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization: Option<EgressAuthorization>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct AddonRef {
pub id: String,
pub source: String,
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetch_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetch_plugin: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct McpPublisherInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_id: Option<String>,
}
#[cfg(test)]
mod mcp_pin_tests {
use super::*;
#[test]
fn pre_m9_entry_roundtrips_without_pin_fields() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: ["--port", "0"]
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(entry.name, "weather");
assert_eq!(entry.binary_sha256, None);
assert_eq!(entry.description_hash, None);
assert_eq!(entry.publisher, None);
assert_eq!(entry.installed_at, None);
let out = serde_yaml_ng::to_string(&entry).unwrap();
assert!(!out.contains("binary_sha256"), "got {out}");
assert!(!out.contains("description_hash"), "got {out}");
assert!(!out.contains("publisher"), "got {out}");
assert!(!out.contains("installed_at"), "got {out}");
}
#[test]
fn full_m9_entry_roundtrips_all_fields() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
publisher:
name: "@anthropic-mcp/weather"
homepage: "https://github.com/anthropic-mcp/weather"
registry_id: "@anthropic-mcp/weather@1.2.3"
installed_at: "2026-05-06T08:00:00Z"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert!(
entry
.binary_sha256
.as_deref()
.unwrap()
.starts_with("3f4abca8")
);
assert!(
entry
.description_hash
.as_deref()
.unwrap()
.starts_with("9a01b2c3")
);
let pub_info = entry.publisher.clone().unwrap();
assert_eq!(pub_info.name, "@anthropic-mcp/weather");
assert_eq!(
pub_info.homepage.as_deref(),
Some("https://github.com/anthropic-mcp/weather"),
);
assert_eq!(
pub_info.registry_id.as_deref(),
Some("@anthropic-mcp/weather@1.2.3"),
);
let installed = entry.installed_at.unwrap();
assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
}
#[test]
fn partial_pin_only_binary_sha_roundtrips() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
entry.binary_sha256.as_deref(),
Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
);
assert_eq!(entry.description_hash, None);
assert_eq!(entry.publisher, None);
}
#[test]
fn publisher_minimal_just_name() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
publisher:
name: "alice"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
let p = entry.publisher.as_ref().unwrap();
assert_eq!(p.name, "alice");
assert_eq!(p.homepage, None);
assert_eq!(p.registry_id, None);
let out = serde_yaml_ng::to_string(&entry).unwrap();
assert!(!out.contains("homepage:"), "got {out}");
assert!(!out.contains("registry_id:"), "got {out}");
}
}
#[cfg(test)]
mod remote_mcp_tests {
use super::*;
#[test]
fn mcp_entry_roundtrips_remote_bearer() {
let e = McpServerEntry {
name: "gh".into(),
command: String::new(),
url: Some("https://api.example.com/mcp".into()),
auth: Some(McpAuth::Bearer {
token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
}),
..Default::default()
};
let y = serde_yaml_ng::to_string(&e).unwrap();
let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
assert!(matches!(
back.auth,
Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
));
let legacy: McpServerEntry =
serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
assert!(legacy.url.is_none());
assert!(legacy.auth.is_none());
}
}
#[cfg(test)]
mod requires_programs_tests {
#[test]
fn mcp_entry_parses_requires_programs_and_defaults_empty() {
let with = r#"
name: research-gateway
command: mur-research-gateway
requires_programs:
- name: lightpanda
detect: { file: "~/.mur/aura/lightpanda" }
reason: "render tier"
registry: lightpanda
"#;
let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
assert_eq!(e.requires_programs.len(), 1);
assert_eq!(e.requires_programs[0].name, "lightpanda");
let without = "name: x\ncommand: y\n";
let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
assert!(e2.requires_programs.is_empty());
}
}