1use std::collections::BTreeMap;
17
18use serde::Deserialize;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum McpTransport<'a> {
23 Stdio {
25 command: &'a [String],
27 },
28 Http {
30 url: &'a str,
32 },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct McpServer {
44 #[serde(default)]
46 pub command: Option<Vec<String>>,
47 #[serde(default)]
49 pub url: Option<String>,
50 #[serde(default)]
55 pub env: BTreeMap<String, String>,
56 #[serde(default)]
61 pub env_from: Vec<String>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
66pub enum McpError {
67 #[error("declares neither `command` nor `url`")]
69 Neither,
70 #[error("declares both `command` and `url`; give exactly one")]
72 Both,
73}
74
75impl McpServer {
76 pub fn transport(&self) -> Result<McpTransport<'_>, McpError> {
82 match (self.command.as_deref(), self.url.as_deref()) {
83 (Some(command), None) => Ok(McpTransport::Stdio { command }),
84 (None, Some(url)) => Ok(McpTransport::Http { url }),
85 (Some(_), Some(_)) => Err(McpError::Both),
86 (None, None) => Err(McpError::Neither),
87 }
88 }
89}
90
91const SECRET_HINTS: &[&str] = &[
93 "TOKEN",
94 "SECRET",
95 "PASSWORD",
96 "PASSWD",
97 "APIKEY",
98 "API_KEY",
99 "ACCESS_KEY",
100 "PRIVATE_KEY",
101 "CREDENTIAL",
102 "_PAT",
103 "PAT_",
104 "SESSION_KEY",
105 "CLIENT_SECRET",
106];
107
108#[must_use]
114pub fn looks_like_a_secret(name: &str) -> bool {
115 let upper = name.to_ascii_uppercase();
116 SECRET_HINTS.iter().any(|hint| upper.contains(hint))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn server(body: &str) -> McpServer {
124 toml::from_str(body).expect("server parses")
125 }
126
127 #[test]
128 fn a_stdio_server_is_a_command() {
129 let server = server(r#"command = ["agency", "mcp", "kusto"]"#);
130
131 assert_eq!(
132 server.transport(),
133 Ok(McpTransport::Stdio {
134 command: ["agency", "mcp", "kusto"].map(str::to_owned).as_slice()
135 })
136 );
137 }
138
139 #[test]
140 fn an_http_server_is_a_url() {
141 let server = server(r#"url = "https://api.example.com/mcp/""#);
142
143 assert_eq!(
144 server.transport(),
145 Ok(McpTransport::Http {
146 url: "https://api.example.com/mcp/"
147 })
148 );
149 }
150
151 #[test]
152 fn non_secret_environment_is_kept_literal() {
153 let server = server(
154 r#"
155 command = ["agency", "mcp", "kusto"]
156 env = { KUSTO_CLUSTER = "ic3-aria-eus2", KUSTO_DATABASE = "Web Media Prod" }
157 env_from = ["AZURE_TENANT_ID"]
158 "#,
159 );
160
161 assert_eq!(server.env["KUSTO_CLUSTER"], "ic3-aria-eus2");
162 assert_eq!(server.env_from, ["AZURE_TENANT_ID"]);
163 }
164
165 #[test]
166 fn declaring_both_transports_is_refused() {
167 let server = server(
168 r#"
169 command = ["x"]
170 url = "https://example.com"
171 "#,
172 );
173
174 assert_eq!(server.transport(), Err(McpError::Both));
175 }
176
177 #[test]
178 fn a_server_that_is_neither_command_nor_url_is_rejected() {
179 let server = server(r#"env = { A = "b" }"#);
180
181 assert_eq!(server.transport(), Err(McpError::Neither));
182 }
183
184 #[test]
185 fn a_typo_in_a_server_field_is_rejected() {
186 assert!(toml::from_str::<McpServer>(r#"comand = ["x"]"#).is_err());
187 }
188
189 #[test]
190 fn credential_shaped_names_are_recognised() {
191 for name in [
192 "GITHUB_TOKEN",
193 "github_token",
194 "AZURE_CLIENT_SECRET",
195 "API_KEY",
196 "apikey",
197 "ADO_PAT_VALUE",
198 "MY_PASSWORD",
199 "AWS_ACCESS_KEY_ID",
200 "SSH_PRIVATE_KEY",
201 ] {
202 assert!(looks_like_a_secret(name), "`{name}` should be flagged");
203 }
204 }
205
206 #[test]
207 fn ordinary_names_are_left_alone() {
208 for name in [
209 "KUSTO_CLUSTER",
210 "AZURE_TENANT_ID",
211 "HOME",
212 "RUST_LOG",
213 "DATABASE",
214 "REGION",
215 ] {
216 assert!(!looks_like_a_secret(name), "`{name}` should not be flagged");
217 }
218 }
219}