use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use crate::agent_identity::{Secret, SecretRef};
use crate::error::{NewtError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
#[default]
Stdio,
Sse,
Http,
}
impl TransportKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Stdio => "stdio",
Self::Sse => "sse",
Self::Http => "http",
}
}
#[must_use]
pub fn from_keyword(s: &str) -> Option<Self> {
match s {
"stdio" => Some(Self::Stdio),
"sse" => Some(Self::Sse),
"http" => Some(Self::Http),
_ => None,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum McpTrust {
#[default]
Trusted,
Untrusted,
}
pub fn resolve_secret_under_trust(value: &SecretValue, trust: McpTrust) -> Result<Secret> {
match trust {
McpTrust::Trusted => value.resolve(),
McpTrust::Untrusted => match value {
SecretValue::Literal(s) => Ok(Secret::new(s.clone())),
SecretValue::Ref(_) => Err(NewtError::Config(
"a discovered (untrusted) MCP config source (a project `.mcp.json` or \
`~/.claude.json`) may not use a `{ env | file | cmd }` secret reference — \
only newt-owned config (`config.toml`, `~/.newt/mcp.toml`) may name a command \
to run or a file to read. `newt mcp import` this server to adopt it as trusted."
.to_string(),
)),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SecretValue {
Literal(String),
Ref(SecretRef),
}
impl SecretValue {
pub fn literal(value: impl Into<String>) -> Self {
Self::Literal(value.into())
}
#[must_use]
pub fn as_literal(&self) -> Option<&str> {
match self {
Self::Literal(s) => Some(s),
Self::Ref(_) => None,
}
}
pub fn resolve(&self) -> Result<Secret> {
match self {
Self::Literal(s) => Ok(Secret::new(interpolate(s)?)),
Self::Ref(r) => r.resolve()?.ok_or_else(|| {
NewtError::Config(
"MCP secret reference resolved to nothing (missing env var, empty file, \
or empty command output)"
.to_string(),
)
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InterpToken {
Env(String),
File(String),
Cmd(String),
}
impl InterpToken {
fn to_secret_ref(&self) -> SecretRef {
match self {
Self::Env(v) => SecretRef {
env: Some(v.clone()),
..Default::default()
},
Self::File(p) => SecretRef {
file: Some(p.clone()),
..Default::default()
},
Self::Cmd(c) => SecretRef {
cmd: Some(c.clone()),
..Default::default()
},
}
}
fn describe(&self) -> String {
match self {
Self::Env(v) => format!("${{env:{v}}}"),
Self::File(p) => format!("${{file:{p}}}"),
Self::Cmd(c) => format!("${{cmd:{c}}}"),
}
}
}
fn is_env_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn classify_token(inner: &str) -> Option<InterpToken> {
match inner.split_once(':') {
Some(("env", rest)) => Some(InterpToken::Env(rest.to_string())),
Some(("file", rest)) => Some(InterpToken::File(rest.to_string())),
Some(("cmd", rest)) => Some(InterpToken::Cmd(rest.to_string())),
Some(_) => None,
None => is_env_identifier(inner).then(|| InterpToken::Env(inner.to_string())),
}
}
fn interpolate_with<F>(template: &str, resolve: F) -> Result<String>
where
F: Fn(&InterpToken) -> Result<String>,
{
if !template.contains("${") {
return Ok(template.to_string());
}
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("${") {
if start >= 1 && rest.as_bytes()[start - 1] == b'$' {
out.push_str(&rest[..start - 1]);
out.push_str("${");
rest = &rest[start + 2..];
continue;
}
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
return Err(NewtError::Config(
"unterminated `${` in an MCP env/header value (missing closing `}`)".to_string(),
));
};
let inner = &after[..end];
match classify_token(inner) {
Some(token) => out.push_str(&resolve(&token)?),
None => {
out.push_str("${");
out.push_str(inner);
out.push('}');
}
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(out)
}
fn resolve_token_live(token: &InterpToken) -> Result<String> {
match token.to_secret_ref().resolve()? {
Some(secret) => Ok(secret.expose().to_string()),
None => Err(NewtError::Config(format!(
"{} resolved to nothing (missing env var, empty file, or empty command output)",
token.describe()
))),
}
}
pub fn interpolate(template: &str) -> Result<String> {
interpolate_with(template, resolve_token_live)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpServerEntry {
#[serde(default)]
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, rename = "type")]
pub transport: TransportKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, SecretValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, SecretValue>,
#[serde(
default,
alias = "requestTimeoutSecs",
skip_serializing_if = "Option::is_none"
)]
pub request_timeout_secs: Option<u64>,
#[serde(skip)]
pub trust: McpTrust,
}
impl McpServerEntry {
pub fn is_valid(&self) -> bool {
match self.transport {
TransportKind::Stdio => self.command.is_some(),
TransportKind::Sse | TransportKind::Http => self.url.is_some(),
}
}
}
pub(crate) fn validate_entry_for_write(entry: &McpServerEntry) -> crate::error::Result<()> {
if entry.name.trim().is_empty() {
return Err(crate::error::NewtError::Config(
"MCP server name cannot be empty".to_string(),
));
}
if !entry.is_valid() {
let need = match entry.transport {
TransportKind::Stdio => "a `command`",
TransportKind::Sse | TransportKind::Http => "a `url`",
};
return Err(crate::error::NewtError::Config(format!(
"a {} MCP server requires {need}",
entry.transport.as_str()
)));
}
Ok(())
}
pub(crate) fn entry_to_toml_table(
entry: &McpServerEntry,
description: Option<&str>,
) -> crate::error::Result<toml_edit::Table> {
let mut table = toml_edit::Table::new();
table["name"] = toml_edit::value(&entry.name);
if let Some(description) = description {
table["description"] = toml_edit::value(description);
}
if !entry.enabled {
table["enabled"] = toml_edit::value(false);
}
if entry.transport != TransportKind::Stdio {
table["type"] = toml_edit::value(entry.transport.as_str());
}
if let Some(command) = &entry.command {
table["command"] = toml_edit::value(command);
}
if !entry.args.is_empty() {
table["args"] = toml_edit::value(toml_edit::Array::from_iter(&entry.args));
}
if !entry.env.is_empty() {
table["env"] = toml_edit::value(secret_map_to_inline_table(&entry.env));
}
if let Some(url) = &entry.url {
table["url"] = toml_edit::value(url);
}
if !entry.headers.is_empty() {
table["headers"] = toml_edit::value(secret_map_to_inline_table(&entry.headers));
}
if let Some(secs) = entry.request_timeout_secs {
table["request_timeout_secs"] = toml_edit::value(i64::try_from(secs).map_err(|_| {
crate::error::NewtError::Config(format!("request timeout {secs}s is out of range"))
})?);
}
Ok(table)
}
fn secret_value_to_toml(value: &SecretValue) -> toml_edit::Value {
match value {
SecretValue::Literal(s) => toml_edit::Value::from(s.as_str()),
SecretValue::Ref(r) => {
let mut table = toml_edit::InlineTable::new();
if let Some(env) = &r.env {
table.insert("env", env.as_str().into());
}
if let Some(file) = &r.file {
table.insert("file", file.as_str().into());
}
if let Some(cmd) = &r.cmd {
table.insert("cmd", cmd.as_str().into());
}
toml_edit::Value::InlineTable(table)
}
}
}
fn secret_map_to_inline_table(map: &BTreeMap<String, SecretValue>) -> toml_edit::InlineTable {
let mut table = toml_edit::InlineTable::new();
for (k, v) in map {
table.insert(k, secret_value_to_toml(v));
}
table
}
pub fn parse_claude_mcp(value: &serde_json::Value) -> Vec<McpServerEntry> {
let Some(map) = value
.get("mcpServers")
.and_then(serde_json::Value::as_object)
else {
return Vec::new();
};
map.iter()
.filter_map(|(name, entry)| {
let mut parsed: McpServerEntry = serde_json::from_value(entry.clone()).ok()?;
parsed.name = name.clone();
parsed.trust = McpTrust::Untrusted;
Some(parsed)
})
.collect()
}
fn load_claude_file(path: &Path) -> Vec<McpServerEntry> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
parse_claude_mcp(&value)
}
pub fn parse_newt_mcp_toml(text: &str) -> Vec<McpServerEntry> {
#[derive(Deserialize, Default)]
struct Doc {
#[serde(default)]
mcp_servers: Vec<McpServerEntry>,
}
toml::from_str::<Doc>(text)
.map(|d| d.mcp_servers)
.unwrap_or_default()
}
fn load_newt_mcp_toml(path: &Path) -> Vec<McpServerEntry> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
parse_newt_mcp_toml(&text)
}
fn dedup_valid_first_wins(sources: Vec<McpServerEntry>) -> Vec<McpServerEntry> {
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut out = Vec::new();
for entry in sources {
if entry.is_valid() && seen.insert(entry.name.clone()) {
out.push(entry);
}
}
out
}
pub fn discover(
newt_servers: &[McpServerEntry],
newt_mcp_toml: Option<&Path>,
home: Option<&Path>,
workspace: &Path,
) -> Vec<McpServerEntry> {
let trusted = |mut e: McpServerEntry| {
e.trust = McpTrust::Trusted;
e
};
let preserve_or_trust = |mut e: McpServerEntry| {
if e.trust != McpTrust::Untrusted {
e.trust = McpTrust::Trusted;
}
e
};
let untrusted = |mut e: McpServerEntry| {
e.trust = McpTrust::Untrusted;
e
};
let mut sources: Vec<McpServerEntry> = Vec::new();
sources.extend(newt_servers.iter().cloned().map(preserve_or_trust));
if let Some(path) = newt_mcp_toml {
sources.extend(load_newt_mcp_toml(path).into_iter().map(trusted));
}
if let Some(home) = home {
sources.extend(
load_claude_file(&home.join(".claude.json"))
.into_iter()
.map(untrusted),
);
}
sources.extend(
load_claude_file(&workspace.join(".mcp.json"))
.into_iter()
.map(untrusted),
);
dedup_valid_first_wins(sources)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_keywords_round_trip() {
for kind in [
TransportKind::Stdio,
TransportKind::Sse,
TransportKind::Http,
] {
assert_eq!(TransportKind::from_keyword(kind.as_str()), Some(kind));
}
assert_eq!(TransportKind::Stdio.as_str(), "stdio");
assert_eq!(TransportKind::from_keyword("grpc"), None);
}
#[test]
fn parses_claude_stdio_and_sse_entries() {
let cfg = serde_json::json!({
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@mcp/fs"], "env": { "ROOT": "/tmp" } },
"remote": { "type": "sse", "url": "https://mcp.example/sse", "headers": { "Authorization": "Bearer x" } }
}
});
let mut got = parse_claude_mcp(&cfg);
got.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(got.len(), 2);
let fs = got.iter().find(|e| e.name == "filesystem").unwrap();
assert_eq!(fs.transport, TransportKind::Stdio); assert_eq!(fs.command.as_deref(), Some("npx"));
assert_eq!(fs.args, vec!["-y", "@mcp/fs"]);
assert_eq!(
fs.env.get("ROOT").and_then(SecretValue::as_literal),
Some("/tmp")
);
let remote = got.iter().find(|e| e.name == "remote").unwrap();
assert_eq!(remote.transport, TransportKind::Sse);
assert_eq!(remote.url.as_deref(), Some("https://mcp.example/sse"));
}
#[test]
fn missing_mcpservers_key_is_empty_not_error() {
assert!(parse_claude_mcp(&serde_json::json!({ "other": 1 })).is_empty());
}
#[test]
fn invalid_entries_are_dropped() {
let stdio_no_cmd = McpServerEntry {
enabled: true,
name: "a".into(),
transport: TransportKind::Stdio,
command: None,
args: vec![],
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
request_timeout_secs: None,
trust: McpTrust::Trusted,
};
let sse_no_url = McpServerEntry {
transport: TransportKind::Sse,
..stdio_no_cmd.clone()
};
assert!(!stdio_no_cmd.is_valid());
assert!(!sse_no_url.is_valid());
let got = discover(
&[stdio_no_cmd, sse_no_url],
None,
None,
Path::new("/nonexistent"),
);
assert!(got.is_empty());
}
#[test]
fn newt_entry_wins_on_name_clash_and_dedups() {
let newt = vec![
McpServerEntry {
enabled: true,
name: "dup".into(),
transport: TransportKind::Stdio,
command: Some("newt-one".into()),
args: vec![],
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
request_timeout_secs: None,
trust: McpTrust::Trusted,
},
McpServerEntry {
enabled: true,
name: "dup".into(),
transport: TransportKind::Stdio,
command: Some("newt-two".into()),
args: vec![],
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
request_timeout_secs: None,
trust: McpTrust::Trusted,
},
];
let got = discover(&newt, None, None, Path::new("/nonexistent"));
assert_eq!(got.len(), 1);
assert_eq!(got[0].command.as_deref(), Some("newt-one"));
}
#[test]
fn discovers_from_claude_user_and_project_files() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let ws = dir.path().join("ws");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
home.join(".claude.json"),
r#"{ "mcpServers": { "user_srv": { "command": "u" } } }"#,
)
.unwrap();
std::fs::write(
ws.join(".mcp.json"),
r#"{ "mcpServers": { "proj_srv": { "command": "p" } } }"#,
)
.unwrap();
let got = discover(&[], None, Some(&home), &ws);
let names: std::collections::BTreeSet<_> = got.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains("user_srv"),
"user config discovered: {names:?}"
);
assert!(
names.contains("proj_srv"),
"project config discovered: {names:?}"
);
}
#[derive(Deserialize)]
struct Holder {
v: SecretValue,
}
#[test]
fn secret_value_string_deserializes_to_literal() {
let toml_h: Holder = toml::from_str(r#"v = "hello""#).unwrap();
assert_eq!(toml_h.v, SecretValue::Literal("hello".into()));
let json_v: SecretValue = serde_json::from_value(serde_json::json!("hi")).unwrap();
assert_eq!(json_v, SecretValue::Literal("hi".into()));
}
#[test]
fn secret_value_table_deserializes_to_ref() {
let cmd_h: Holder =
toml::from_str(r#"v = { cmd = "vault kv get -field=token secret/gh" }"#).unwrap();
assert_eq!(
cmd_h.v,
SecretValue::Ref(SecretRef {
cmd: Some("vault kv get -field=token secret/gh".into()),
..Default::default()
})
);
let env_h: Holder = toml::from_str(r#"v = { env = "TOK" }"#).unwrap();
assert!(matches!(
env_h.v,
SecretValue::Ref(SecretRef { env: Some(_), .. })
));
let file_h: Holder = toml::from_str(r#"v = { file = "~/.secrets/x" }"#).unwrap();
assert!(matches!(
file_h.v,
SecretValue::Ref(SecretRef { file: Some(_), .. })
));
}
#[test]
fn secret_value_literal_resolves_verbatim_without_tokens() {
assert_eq!(
SecretValue::literal("info").resolve().unwrap().expose(),
"info"
);
assert_eq!(SecretValue::literal("").resolve().unwrap().expose(), "");
}
#[test]
fn secret_value_as_literal_and_roundtrips_through_toml() {
assert_eq!(SecretValue::literal("x").as_literal(), Some("x"));
assert_eq!(SecretValue::Ref(SecretRef::default()).as_literal(), None);
#[derive(Serialize)]
struct H {
v: SecretValue,
}
let lit = toml::to_string(&H {
v: SecretValue::literal("hi"),
})
.unwrap();
assert!(lit.contains("v = \"hi\""), "{lit}");
let refd = toml::to_string(&H {
v: SecretValue::Ref(SecretRef {
cmd: Some("vault kv get x".into()),
..Default::default()
}),
})
.unwrap();
assert!(refd.contains("cmd"), "{refd}");
assert!(refd.contains("vault kv get x"), "{refd}");
}
#[test]
fn interpolate_returns_a_token_free_literal_verbatim() {
let out = interpolate_with("Bearer plain /tmp org-12345", |_| {
panic!("no token should be resolved")
})
.unwrap();
assert_eq!(out, "Bearer plain /tmp org-12345");
}
#[test]
fn interpolate_embeds_a_token_in_surrounding_literal() {
let out = interpolate_with(
"Bearer ${cmd:vault kv get -field=token secret/data/github}!",
|t| {
assert_eq!(
t,
&InterpToken::Cmd("vault kv get -field=token secret/data/github".into())
);
Ok("RESOLVED".to_string())
},
)
.unwrap();
assert_eq!(out, "Bearer RESOLVED!");
}
#[test]
fn interpolate_handles_multiple_tokens_of_every_scheme() {
let out = interpolate_with("${env:A}/${file:/p}/${X}", |t| {
Ok(match t {
InterpToken::Env(v) => format!("E<{v}>"),
InterpToken::File(p) => format!("F<{p}>"),
InterpToken::Cmd(c) => format!("C<{c}>"),
})
})
.unwrap();
assert_eq!(out, "E<A>/F</p>/E<X>");
}
#[test]
fn classify_token_maps_schemes_and_leaves_unrecognized_verbatim() {
assert_eq!(
classify_token("VAR").unwrap(),
InterpToken::Env("VAR".into())
);
assert_eq!(
classify_token("env:VAR").unwrap(),
InterpToken::Env("VAR".into())
);
assert_eq!(
classify_token("file:~/.secrets/x").unwrap(),
InterpToken::File("~/.secrets/x".into())
);
assert_eq!(
classify_token("cmd:vault kv get -field=token secret/x").unwrap(),
InterpToken::Cmd("vault kv get -field=token secret/x".into())
);
assert!(classify_token("bogus:thing").is_none());
assert!(classify_token("VAR:-https://api.example.com").is_none());
assert!(classify_token(".field").is_none());
assert!(classify_token("1abc").is_none());
assert!(classify_token("a b").is_none());
assert_eq!(
classify_token("_MY_TOKEN2").unwrap(),
InterpToken::Env("_MY_TOKEN2".into())
);
}
#[test]
fn interpolate_passes_unrecognized_tokens_through_verbatim() {
let never = |_: &InterpToken| -> Result<String> { panic!("must not resolve") };
assert_eq!(
interpolate_with("${API_BASE:-https://api.example.com}", never).unwrap(),
"${API_BASE:-https://api.example.com}"
);
assert_eq!(interpolate_with("${.field}", never).unwrap(), "${.field}");
let out = interpolate_with("${env:A}-${x:y}", |t| {
Ok(match t {
InterpToken::Env(v) => format!("E<{v}>"),
_ => unreachable!(),
})
})
.unwrap();
assert_eq!(out, "E<A>-${x:y}");
}
#[test]
fn interpolate_double_dollar_escapes_a_literal_dollar_brace() {
let never = |_: &InterpToken| -> Result<String> { panic!("must not resolve") };
assert_eq!(
interpolate_with("price $${cmd:evil}", never).unwrap(),
"price ${cmd:evil}"
);
assert_eq!(interpolate_with("$${VAR}", never).unwrap(), "${VAR}");
let out = interpolate_with("$5 then ${env:A}", |t| match t {
InterpToken::Env(v) => Ok(format!("<{v}>")),
_ => unreachable!(),
})
.unwrap();
assert_eq!(out, "$5 then <A>");
}
#[test]
fn interpolate_missing_reference_fails_loudly_not_empty() {
let err = interpolate_with("${env:MISSING}", |_| {
Err(NewtError::Config("environment variable not set".into()))
})
.unwrap_err();
assert!(format!("{err}").contains("not set"));
}
#[test]
fn interpolate_unterminated_token_errors_without_leaking_the_value() {
let err = interpolate_with("sk-live-DEADBEEF ${", |_| Ok("x".into())).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unterminated"), "{msg}");
assert!(
!msg.contains("sk-live-DEADBEEF"),
"the raw value leaked into the error: {msg}"
);
}
#[test]
fn untrusted_literal_with_cmd_token_passes_through_verbatim_no_execution() {
let value = SecretValue::literal("${cmd:touch /tmp/newt-1301-should-not-exist}");
let got = resolve_secret_under_trust(&value, McpTrust::Untrusted).unwrap();
assert_eq!(
got.expose(),
"${cmd:touch /tmp/newt-1301-should-not-exist}",
"an untrusted ${{cmd:…}} literal must reach the child verbatim, not run"
);
let bare = SecretValue::literal("Bearer ${SOME_VAR}");
assert_eq!(
resolve_secret_under_trust(&bare, McpTrust::Untrusted)
.unwrap()
.expose(),
"Bearer ${SOME_VAR}"
);
}
#[test]
fn untrusted_structured_ref_is_rejected() {
for r in [
SecretRef {
cmd: Some("touch /tmp/pwned".into()),
..Default::default()
},
SecretRef {
file: Some("/etc/passwd".into()),
..Default::default()
},
SecretRef {
env: Some("HOME".into()),
..Default::default()
},
] {
let err = resolve_secret_under_trust(&SecretValue::Ref(r), McpTrust::Untrusted)
.expect_err("an untrusted {env|file|cmd} ref must be rejected");
assert!(
format!("{err}").contains("untrusted"),
"error should name the trust violation: {err}"
);
}
}
#[test]
fn trusted_literal_without_token_resolves_verbatim() {
assert_eq!(
resolve_secret_under_trust(&SecretValue::literal("plain"), McpTrust::Trusted)
.unwrap()
.expose(),
"plain"
);
}
#[test]
fn discover_marks_newt_sources_trusted_and_claude_untrusted() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path();
std::fs::write(
ws.join(".mcp.json"),
r#"{ "mcpServers": { "proj": { "command": "p" } } }"#,
)
.unwrap();
let got = discover(&[stdio("owned", "o")], None, None, ws);
let owned = got.iter().find(|e| e.name == "owned").unwrap();
let proj = got.iter().find(|e| e.name == "proj").unwrap();
assert_eq!(owned.trust, McpTrust::Trusted);
assert_eq!(proj.trust, McpTrust::Untrusted);
}
#[test]
fn discover_preserves_untrusted_project_origin_and_does_not_re_elevate() {
let mut project_origin = stdio("proj", "p");
project_origin.trust = McpTrust::Untrusted;
let owned = stdio("owned", "o");
let got = discover(
&[project_origin, owned],
None,
None,
Path::new("/nonexistent"),
);
assert_eq!(
got.iter().find(|e| e.name == "proj").unwrap().trust,
McpTrust::Untrusted,
"a project-origin Untrusted mark must survive discover(), not be re-elevated"
);
assert_eq!(
got.iter().find(|e| e.name == "owned").unwrap().trust,
McpTrust::Trusted,
"a genuine newt-owned entry is (still) trusted"
);
}
#[test]
fn parse_claude_mcp_marks_every_entry_untrusted() {
let cfg = serde_json::json!({
"mcpServers": { "x": { "command": "c", "env": { "K": "v" } } }
});
let got = parse_claude_mcp(&cfg);
assert!(got.iter().all(|e| e.trust == McpTrust::Untrusted));
}
fn stdio(name: &str, command: &str) -> McpServerEntry {
McpServerEntry {
name: name.into(),
enabled: true,
transport: TransportKind::Stdio,
command: Some(command.into()),
args: vec![],
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
request_timeout_secs: None,
trust: McpTrust::Trusted,
}
}
#[test]
fn parse_newt_mcp_toml_reads_servers_and_tolerates_garbage() {
let text = r#"
[[mcp_servers]]
name = "a"
command = "a-mcp"
[[mcp_servers]]
name = "b"
type = "http"
url = "https://x/mcp"
"#;
let got = parse_newt_mcp_toml(text);
assert_eq!(got.len(), 2);
assert_eq!(got[0].name, "a");
assert_eq!(got[0].command.as_deref(), Some("a-mcp"));
assert_eq!(got[1].transport, TransportKind::Http);
assert!(parse_newt_mcp_toml("not = = toml [").is_empty());
assert!(parse_newt_mcp_toml("other = 1").is_empty());
}
#[test]
fn parse_newt_mcp_toml_reads_secret_refs_and_literals() {
let text = r#"
[[mcp_servers]]
name = "gh"
command = "gh-mcp"
[mcp_servers.env]
GH_TOKEN = { cmd = "vault kv get -field=token secret/gh" }
RUST_LOG = "debug"
"#;
let got = parse_newt_mcp_toml(text);
assert_eq!(got.len(), 1);
assert_eq!(
got[0].env.get("RUST_LOG"),
Some(&SecretValue::literal("debug"))
);
assert!(matches!(
got[0].env.get("GH_TOKEN"),
Some(SecretValue::Ref(SecretRef { cmd: Some(_), .. }))
));
}
#[test]
fn discover_ranks_config_over_mcp_toml_over_claude() {
let merged = dedup_valid_first_wins(vec![
stdio("dup", "config-wins"),
stdio("mcp-only", "m"),
stdio("dup", "mcp-toml-loses"),
stdio("claude-only", "c"),
stdio("dup", "claude-loses"),
]);
let names: Vec<&str> = merged.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["dup", "mcp-only", "claude-only"]);
assert_eq!(merged[0].command.as_deref(), Some("config-wins"));
}
#[test]
fn discover_reads_mcp_toml_as_a_newt_owned_source() {
let dir = tempfile::tempdir().unwrap();
let mcp_toml = dir.path().join("mcp.toml");
std::fs::write(
&mcp_toml,
"[[mcp_servers]]\nname = \"broken-out\"\ncommand = \"bo-mcp\"\n",
)
.unwrap();
let got = discover(&[], Some(&mcp_toml), None, Path::new("/nonexistent"));
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "broken-out");
assert!(discover(
&[],
Some(Path::new("/no/such/mcp.toml")),
None,
Path::new("/nope")
)
.is_empty());
}
}