Skip to main content

layover_core/
mcp.rs

1//! MCP servers an agent may reach.
2//!
3//! Layover is itself an MCP server — that is how agents send flights. This module is about the
4//! *other* servers an agent needs to do its job: a telemetry agent that queries Kusto, a
5//! publisher that talks to Azure DevOps. Without this, those have to be configured outside
6//! Layover in each CLI's own settings, where the route map cannot see them and nothing validates
7//! them.
8//!
9//! # Secrets do not live here
10//!
11//! `layover.toml` is a file people commit. Credentials reach servers through [`McpServer::env_from`],
12//! which forwards named variables from the Tower's own environment, and validation refuses a
13//! literal that looks like a credential. This is the same rule as everywhere else in Layover: keys
14//! reach child processes through the environment, never through config.
15
16use std::collections::BTreeMap;
17
18use serde::Deserialize;
19
20/// How Layover reaches an MCP server.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum McpTransport<'a> {
23    /// A subprocess speaking MCP over stdio.
24    Stdio {
25        /// Command and arguments.
26        command: &'a [String],
27    },
28    /// An HTTP endpoint.
29    Http {
30        /// Where the server lives.
31        url: &'a str,
32    },
33}
34
35/// One MCP server, as declared for an agent.
36///
37/// Exactly one of `command` and `url` must be given. They are separate optional fields rather
38/// than an untagged enum because `deny_unknown_fields` — which is what catches a typo like
39/// `comand` — cannot be combined with a flattened enum, and silently accepting a misspelt field
40/// would leave an agent without the server it thinks it has.
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct McpServer {
44    /// Command and arguments, for a server spoken to over stdio.
45    #[serde(default)]
46    pub command: Option<Vec<String>>,
47    /// Endpoint, for a server spoken to over HTTP.
48    #[serde(default)]
49    pub url: Option<String>,
50    /// Non-secret environment variables, set literally.
51    ///
52    /// For values that are safe in a committed file: a cluster name, a region, a default
53    /// database. Anything that authenticates belongs in [`McpServer::env_from`].
54    #[serde(default)]
55    pub env: BTreeMap<String, String>,
56    /// Names of variables forwarded from the Tower's own environment.
57    ///
58    /// The Tower reads these at spawn time and passes them through. The value never appears in
59    /// `layover.toml`, so the file stays committable.
60    #[serde(default)]
61    pub env_from: Vec<String>,
62}
63
64/// Why an MCP server declaration is unusable.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
66pub enum McpError {
67    /// Neither `command` nor `url` was given.
68    #[error("declares neither `command` nor `url`")]
69    Neither,
70    /// Both were given, so which applies is undefined.
71    #[error("declares both `command` and `url`; give exactly one")]
72    Both,
73}
74
75impl McpServer {
76    /// Returns how this server is reached.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`McpError`] when neither or both of `command` and `url` are set.
81    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
91/// Environment variable names that almost always hold a credential.
92const 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/// Returns `true` when a variable name suggests its value is a credential.
109///
110/// Deliberately name-based rather than value-based: guessing whether a *string* is a secret is
111/// hopeless, while `AZURE_CLIENT_SECRET` announces itself. False positives cost an author one
112/// line — move it to `env_from` — and a false negative costs a leaked key in git history.
113#[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}