use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpTransport<'a> {
Stdio {
command: &'a [String],
},
Http {
url: &'a str,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct McpServer {
#[serde(default)]
pub command: Option<Vec<String>>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub env_from: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum McpError {
#[error("declares neither `command` nor `url`")]
Neither,
#[error("declares both `command` and `url`; give exactly one")]
Both,
}
impl McpServer {
pub fn transport(&self) -> Result<McpTransport<'_>, McpError> {
match (self.command.as_deref(), self.url.as_deref()) {
(Some(command), None) => Ok(McpTransport::Stdio { command }),
(None, Some(url)) => Ok(McpTransport::Http { url }),
(Some(_), Some(_)) => Err(McpError::Both),
(None, None) => Err(McpError::Neither),
}
}
}
const SECRET_HINTS: &[&str] = &[
"TOKEN",
"SECRET",
"PASSWORD",
"PASSWD",
"APIKEY",
"API_KEY",
"ACCESS_KEY",
"PRIVATE_KEY",
"CREDENTIAL",
"_PAT",
"PAT_",
"SESSION_KEY",
"CLIENT_SECRET",
];
#[must_use]
pub fn looks_like_a_secret(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
SECRET_HINTS.iter().any(|hint| upper.contains(hint))
}
#[cfg(test)]
mod tests {
use super::*;
fn server(body: &str) -> McpServer {
toml::from_str(body).expect("server parses")
}
#[test]
fn a_stdio_server_is_a_command() {
let server = server(r#"command = ["agency", "mcp", "kusto"]"#);
assert_eq!(
server.transport(),
Ok(McpTransport::Stdio {
command: ["agency", "mcp", "kusto"].map(str::to_owned).as_slice()
})
);
}
#[test]
fn an_http_server_is_a_url() {
let server = server(r#"url = "https://api.example.com/mcp/""#);
assert_eq!(
server.transport(),
Ok(McpTransport::Http {
url: "https://api.example.com/mcp/"
})
);
}
#[test]
fn non_secret_environment_is_kept_literal() {
let server = server(
r#"
command = ["agency", "mcp", "kusto"]
env = { KUSTO_CLUSTER = "ic3-aria-eus2", KUSTO_DATABASE = "Web Media Prod" }
env_from = ["AZURE_TENANT_ID"]
"#,
);
assert_eq!(server.env["KUSTO_CLUSTER"], "ic3-aria-eus2");
assert_eq!(server.env_from, ["AZURE_TENANT_ID"]);
}
#[test]
fn declaring_both_transports_is_refused() {
let server = server(
r#"
command = ["x"]
url = "https://example.com"
"#,
);
assert_eq!(server.transport(), Err(McpError::Both));
}
#[test]
fn a_server_that_is_neither_command_nor_url_is_rejected() {
let server = server(r#"env = { A = "b" }"#);
assert_eq!(server.transport(), Err(McpError::Neither));
}
#[test]
fn a_typo_in_a_server_field_is_rejected() {
assert!(toml::from_str::<McpServer>(r#"comand = ["x"]"#).is_err());
}
#[test]
fn credential_shaped_names_are_recognised() {
for name in [
"GITHUB_TOKEN",
"github_token",
"AZURE_CLIENT_SECRET",
"API_KEY",
"apikey",
"ADO_PAT_VALUE",
"MY_PASSWORD",
"AWS_ACCESS_KEY_ID",
"SSH_PRIVATE_KEY",
] {
assert!(looks_like_a_secret(name), "`{name}` should be flagged");
}
}
#[test]
fn ordinary_names_are_left_alone() {
for name in [
"KUSTO_CLUSTER",
"AZURE_TENANT_ID",
"HOME",
"RUST_LOG",
"DATABASE",
"REGION",
] {
assert!(!looks_like_a_secret(name), "`{name}` should not be flagged");
}
}
}