Skip to main content

agent_config/spec/mcp/
mod.rs

1//! MCP server spec, builder, and transport enum.
2//!
3//! Layout: this module is a directory split into `transport.rs` (the
4//! [`McpTransport`] enum, transport-shape validation, and secret-detection
5//! helpers) and `builder.rs` (the fluent [`McpSpecBuilder`]). The flat
6//! [`McpSpec`], [`SecretPolicy`], [`McpTransport`], and [`McpSpecBuilder`]
7//! re-exports below are the public surface; the parent `spec` module
8//! re-exports them again at `crate::spec::*`.
9
10use crate::error::AgentConfigError;
11use crate::scope::{Scope, ScopeKind};
12
13use super::validate::{validate_identifier, IdentifierKind};
14
15mod builder;
16mod transport;
17
18pub use builder::McpSpecBuilder;
19pub use transport::McpTransport;
20
21use transport::{is_inline_secret_env_value, is_inline_secret_header_value, validate_transport};
22
23/// Caller-supplied description of an MCP server to register with a harness.
24///
25/// MCP servers are keyed by [`name`](Self::name) (the literal string the harness
26/// uses to load the server), not by an arbitrary tag. To support multi-consumer
27/// coexistence the library records ownership in a sidecar ledger
28/// (`<config-dir>/.agent-config-mcp.json`) keyed by name → `owner_tag`. Removing a
29/// server owned by a different consumer (or by a hand-edit) returns
30/// [`AgentConfigError::NotOwnedByCaller`].
31///
32/// Build via [`McpSpec::builder`]. For fallible construction see
33/// [`McpSpecBuilder::try_build`].
34#[derive(Debug, Clone)]
35pub struct McpSpec {
36    /// Server name. Becomes the key in `mcpServers` (Claude/Cursor/Gemini/
37    /// Copilot/Windsurf), the object-based `mcp` map (OpenCode/Kilo), or the
38    /// table name `[mcp_servers.<name>]` (Codex).
39    /// ASCII alnum/`_`/`-`, non-empty.
40    pub name: String,
41
42    /// The consumer of this library that owns the server, recorded in the
43    /// ownership ledger. ASCII alnum/`_`/`-`, non-empty.
44    pub owner_tag: String,
45
46    /// How the harness should reach the server (stdio launcher, HTTP, or SSE).
47    pub transport: McpTransport,
48
49    /// Optional human-friendly display name surfaced in install reports.
50    pub friendly_name: Option<String>,
51
52    /// Policy for inline env values that look secret-bearing when installing
53    /// into project-local config files.
54    pub secret_policy: SecretPolicy,
55
56    /// When true, an install that finds an entry already present in the
57    /// harness config but absent from the ownership ledger will adopt that
58    /// entry under this spec's `owner_tag` instead of refusing. Use after a
59    /// crash between config write and ledger record (`InstallStatus::PresentUnowned`).
60    /// Default `false`: adoption is opt-in to avoid silently taking over a
61    /// hand-installed entry the user may want to keep separate.
62    pub adopt_unowned: bool,
63}
64
65impl McpSpec {
66    /// Start building an MCP spec with the given server name.
67    pub fn builder(name: impl Into<String>) -> McpSpecBuilder {
68        McpSpecBuilder {
69            name: name.into(),
70            owner_tag: None,
71            transport: None,
72            friendly_name: None,
73            secret_policy: SecretPolicy::RefuseInlineSecretsInLocalScope,
74            adopt_unowned: false,
75            builder_error: None,
76        }
77    }
78
79    /// Validate that both `name` and `owner_tag` use the same safe character
80    /// set as [`HookSpec::tag`](crate::HookSpec::tag).
81    pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
82        Self::validate_name(&self.name)?;
83        validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)?;
84        validate_transport(&self.transport)
85    }
86
87    /// Validate just the server name (used by uninstall, which has no spec).
88    pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
89        validate_identifier(name, IdentifierKind::McpName)
90    }
91
92    /// Enforce this spec's local inline-secret policy for a target scope.
93    pub(crate) fn validate_local_secret_policy(
94        &self,
95        scope: &Scope,
96    ) -> Result<(), AgentConfigError> {
97        if let Some(key) = self.refused_local_inline_secret_key(scope) {
98            return Err(AgentConfigError::InlineSecretInLocalScope {
99                name: self.name.clone(),
100                key: key.to_string(),
101            });
102        }
103        Ok(())
104    }
105
106    /// Returns the first env var or HTTP/SSE header that looks secret-bearing
107    /// when the install scope is local.
108    pub(crate) fn local_inline_secret_key(&self, scope: &Scope) -> Option<&str> {
109        if scope.kind() != ScopeKind::Local {
110            return None;
111        }
112        match &self.transport {
113            McpTransport::Stdio { env, .. } => env
114                .iter()
115                .find(|(key, value)| is_inline_secret_env_value(key, value))
116                .map(|(key, _)| key.as_str()),
117            McpTransport::Http { headers, .. } | McpTransport::Sse { headers, .. } => headers
118                .iter()
119                .find(|(key, value)| is_inline_secret_header_value(key, value))
120                .map(|(key, _)| key.as_str()),
121        }
122    }
123
124    /// Returns the first env or header key refused by the current secret policy.
125    pub(crate) fn refused_local_inline_secret_key(&self, scope: &Scope) -> Option<&str> {
126        if self.secret_policy == SecretPolicy::RefuseInlineSecretsInLocalScope {
127            self.local_inline_secret_key(scope)
128        } else {
129            None
130        }
131    }
132
133    /// Returns the first env or header key allowed by explicit override.
134    pub(crate) fn allowed_local_inline_secret_key(&self, scope: &Scope) -> Option<&str> {
135        if self.secret_policy == SecretPolicy::AllowInlineSecretsInLocalScope {
136            self.local_inline_secret_key(scope)
137        } else {
138            None
139        }
140    }
141}
142
143/// Policy for env values that look secret-bearing in project-local MCP config.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[non_exhaustive]
146pub enum SecretPolicy {
147    /// Refuse local-scope installs that would write likely secret env values.
148    RefuseInlineSecretsInLocalScope,
149    /// Allow likely secret env values even when the config path is local.
150    ///
151    /// Use this only when the caller has made an explicit trust decision about
152    /// the target project config.
153    AllowInlineSecretsInLocalScope,
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn mcp_builder_stdio_round_trip() {
162        let spec = McpSpec::builder("github")
163            .owner("myapp")
164            .stdio("npx", ["-y", "@modelcontextprotocol/server-github"])
165            .env("GITHUB_TOKEN", "abc")
166            .friendly_name("GitHub MCP")
167            .build();
168
169        assert_eq!(spec.name, "github");
170        assert_eq!(spec.owner_tag, "myapp");
171        assert_eq!(spec.friendly_name.as_deref(), Some("GitHub MCP"));
172        match spec.transport {
173            McpTransport::Stdio { command, args, env } => {
174                assert_eq!(command, "npx");
175                assert_eq!(args, vec!["-y", "@modelcontextprotocol/server-github"]);
176                assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("abc"));
177            }
178            other => panic!("expected stdio, got {other:?}"),
179        }
180    }
181
182    #[test]
183    fn mcp_builder_env_from_host_uses_placeholder() {
184        let spec = McpSpec::builder("github")
185            .owner("myapp")
186            .stdio("npx", ["server"])
187            .env_from_host("GITHUB_TOKEN")
188            .build();
189
190        assert_eq!(
191            spec.secret_policy,
192            SecretPolicy::RefuseInlineSecretsInLocalScope
193        );
194        assert!(spec
195            .local_inline_secret_key(&Scope::Local("/tmp/project".into()))
196            .is_none());
197        match spec.transport {
198            McpTransport::Stdio { env, .. } => {
199                assert_eq!(
200                    env.get("GITHUB_TOKEN").map(String::as_str),
201                    Some("${GITHUB_TOKEN}")
202                );
203            }
204            other => panic!("expected stdio, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn local_inline_secret_policy_detects_likely_secret_env() {
210        let local = Scope::Local("/tmp/project".into());
211        let global = Scope::Global;
212        let spec = McpSpec::builder("github")
213            .owner("myapp")
214            .stdio("npx", ["server"])
215            .env("GITHUB_TOKEN", "abc")
216            .build();
217
218        assert_eq!(spec.local_inline_secret_key(&local), Some("GITHUB_TOKEN"));
219        assert!(spec.validate_local_secret_policy(&global).is_ok());
220        assert!(matches!(
221            spec.validate_local_secret_policy(&local),
222            Err(AgentConfigError::InlineSecretInLocalScope { key, .. }) if key == "GITHUB_TOKEN"
223        ));
224
225        let allowed = McpSpec::builder("github")
226            .owner("myapp")
227            .stdio("npx", ["server"])
228            .env("GITHUB_TOKEN", "abc")
229            .allow_local_inline_secrets()
230            .build();
231        assert!(allowed.validate_local_secret_policy(&local).is_ok());
232        assert_eq!(
233            allowed.allowed_local_inline_secret_key(&local),
234            Some("GITHUB_TOKEN")
235        );
236    }
237
238    #[test]
239    fn local_inline_secret_policy_detects_http_authorization_header() {
240        let local = Scope::Local("/tmp/project".into());
241        let global = Scope::Global;
242        let spec = McpSpec::builder("remote")
243            .owner("myapp")
244            .http("https://example.com/mcp")
245            .header("Authorization", "Bearer xyz")
246            .build();
247
248        assert_eq!(spec.local_inline_secret_key(&local), Some("Authorization"));
249        assert!(spec.validate_local_secret_policy(&global).is_ok());
250        assert!(matches!(
251            spec.validate_local_secret_policy(&local),
252            Err(AgentConfigError::InlineSecretInLocalScope { key, .. }) if key == "Authorization"
253        ));
254    }
255
256    #[test]
257    fn local_inline_secret_policy_detects_sse_x_api_key_header() {
258        let local = Scope::Local("/tmp/project".into());
259        let spec = McpSpec::builder("remote")
260            .owner("myapp")
261            .sse("https://example.com/sse")
262            .header("X-API-Key", "abc123")
263            .build();
264
265        assert_eq!(spec.local_inline_secret_key(&local), Some("X-API-Key"));
266        assert!(matches!(
267            spec.validate_local_secret_policy(&local),
268            Err(AgentConfigError::InlineSecretInLocalScope { key, .. }) if key == "X-API-Key"
269        ));
270    }
271
272    #[test]
273    fn local_inline_secret_policy_detects_cookie_header() {
274        let local = Scope::Local("/tmp/project".into());
275        let spec = McpSpec::builder("remote")
276            .owner("myapp")
277            .http("https://example.com/mcp")
278            .header("Cookie", "session=opaque")
279            .build();
280
281        assert_eq!(spec.local_inline_secret_key(&local), Some("Cookie"));
282        assert!(matches!(
283            spec.validate_local_secret_policy(&local),
284            Err(AgentConfigError::InlineSecretInLocalScope { key, .. }) if key == "Cookie"
285        ));
286    }
287
288    #[test]
289    fn local_inline_secret_policy_allows_placeholder_header() {
290        let local = Scope::Local("/tmp/project".into());
291        let spec = McpSpec::builder("remote")
292            .owner("myapp")
293            .http("https://example.com/mcp")
294            .header("Authorization", "${TOKEN}")
295            .build();
296        assert!(spec.local_inline_secret_key(&local).is_none());
297        assert!(spec.validate_local_secret_policy(&local).is_ok());
298    }
299
300    #[test]
301    fn local_inline_secret_policy_allows_innocuous_header() {
302        let local = Scope::Local("/tmp/project".into());
303        let spec = McpSpec::builder("remote")
304            .owner("myapp")
305            .http("https://example.com/mcp")
306            .header("Accept", "application/json")
307            .build();
308        assert!(spec.local_inline_secret_key(&local).is_none());
309        assert!(spec.validate_local_secret_policy(&local).is_ok());
310    }
311
312    #[test]
313    fn mcp_builder_http_with_headers() {
314        let spec = McpSpec::builder("remote")
315            .owner("myapp")
316            .http("https://example.com/mcp")
317            .header("Authorization", "Bearer xyz")
318            .build();
319        match spec.transport {
320            McpTransport::Http { url, headers } => {
321                assert_eq!(url, "https://example.com/mcp");
322                assert_eq!(
323                    headers.get("Authorization").map(String::as_str),
324                    Some("Bearer xyz")
325                );
326            }
327            other => panic!("expected http, got {other:?}"),
328        }
329    }
330
331    #[test]
332    fn mcp_builder_http_accepts_rfc_url_forms() {
333        for url in [
334            "HTTP://example.com/mcp",
335            "https://[2001:db8::1]/mcp?x=y#frag",
336            "https://example.com/a/%7Bencoded%7D",
337        ] {
338            let spec = McpSpec::builder("remote")
339                .owner("myapp")
340                .http(url)
341                .try_build()
342                .unwrap();
343            assert!(matches!(spec.transport, McpTransport::Http { .. }));
344        }
345    }
346
347    #[test]
348    fn mcp_try_build_rejects_missing_owner() {
349        let err = McpSpec::builder("x")
350            .stdio("cmd", Vec::<String>::new())
351            .try_build()
352            .unwrap_err();
353        assert!(
354            matches!(err, AgentConfigError::MissingSpecField { field, .. } if field == "owner")
355        );
356    }
357
358    #[test]
359    fn mcp_try_build_rejects_missing_transport() {
360        let err = McpSpec::builder("x")
361            .owner("myapp")
362            .try_build()
363            .unwrap_err();
364        assert!(
365            matches!(err, AgentConfigError::MissingSpecField { field, .. } if field == "transport")
366        );
367    }
368
369    #[test]
370    fn mcp_try_build_rejects_invalid_name() {
371        let err = McpSpec::builder("bad name")
372            .owner("myapp")
373            .stdio("cmd", Vec::<String>::new())
374            .try_build()
375            .unwrap_err();
376        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
377    }
378
379    #[test]
380    fn mcp_try_build_rejects_invalid_owner() {
381        let err = McpSpec::builder("x")
382            .owner("bad owner")
383            .stdio("cmd", Vec::<String>::new())
384            .try_build()
385            .unwrap_err();
386        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
387    }
388
389    #[test]
390    fn mcp_env_on_non_stdio_is_rejected() {
391        let err = McpSpec::builder("x")
392            .owner("myapp")
393            .http("https://example.com")
394            .env("IGNORED", "yes")
395            .try_build()
396            .unwrap_err();
397        assert!(matches!(err, AgentConfigError::Other(_)));
398    }
399
400    #[test]
401    fn mcp_header_on_stdio_is_rejected() {
402        let err = McpSpec::builder("x")
403            .owner("myapp")
404            .stdio("cmd", Vec::<String>::new())
405            .header("Authorization", "Bearer token")
406            .try_build()
407            .unwrap_err();
408        assert!(matches!(err, AgentConfigError::Other(_)));
409    }
410
411    #[test]
412    fn mcp_env_before_transport_is_rejected() {
413        let err = McpSpec::builder("x")
414            .owner("myapp")
415            .env("FOO", "bar")
416            .stdio("cmd", Vec::<String>::new())
417            .try_build()
418            .unwrap_err();
419        assert!(matches!(err, AgentConfigError::Other(_)));
420    }
421
422    #[test]
423    fn mcp_header_before_transport_is_rejected() {
424        let err = McpSpec::builder("x")
425            .owner("myapp")
426            .header("Authorization", "Bearer token")
427            .http("https://example.com/mcp")
428            .try_build()
429            .unwrap_err();
430        assert!(matches!(err, AgentConfigError::Other(_)));
431    }
432
433    #[test]
434    fn mcp_try_build_rejects_empty_stdio_command() {
435        let err = McpSpec::builder("x")
436            .owner("myapp")
437            .stdio("  ", Vec::<String>::new())
438            .try_build()
439            .unwrap_err();
440        assert!(matches!(err, AgentConfigError::Other(_)));
441    }
442
443    #[test]
444    fn mcp_try_build_rejects_invalid_remote_urls() {
445        for bad in [
446            "",
447            "ftp://example.com/mcp",
448            "https://",
449            "http:///mcp",
450            "http:/mcp",
451            "https://exa mple.com",
452        ] {
453            let err = McpSpec::builder("x")
454                .owner("myapp")
455                .http(bad)
456                .try_build()
457                .unwrap_err();
458            assert!(
459                matches!(err, AgentConfigError::Other(_)),
460                "expected invalid URL for {bad:?}"
461            );
462        }
463    }
464
465    #[test]
466    fn mcp_try_build_rejects_invalid_env_names_and_values() {
467        for key in ["", "BAD=NAME", "BAD\nNAME"] {
468            let err = McpSpec::builder("x")
469                .owner("myapp")
470                .stdio("cmd", Vec::<String>::new())
471                .env(key, "value")
472                .try_build()
473                .unwrap_err();
474            assert!(
475                matches!(err, AgentConfigError::Other(_)),
476                "expected invalid env key for {key:?}"
477            );
478        }
479
480        let err = McpSpec::builder("x")
481            .owner("myapp")
482            .stdio("cmd", Vec::<String>::new())
483            .env("GOOD_NAME", "line\nbreak")
484            .try_build()
485            .unwrap_err();
486        assert!(matches!(err, AgentConfigError::Other(_)));
487    }
488
489    #[test]
490    fn mcp_try_build_rejects_invalid_header_names_and_values() {
491        for key in ["", "Bad Header", "Bad:Header", "Bad\nHeader"] {
492            let err = McpSpec::builder("x")
493                .owner("myapp")
494                .http("https://example.com/mcp")
495                .header(key, "value")
496                .try_build()
497                .unwrap_err();
498            assert!(
499                matches!(err, AgentConfigError::Other(_)),
500                "expected invalid header key for {key:?}"
501            );
502        }
503
504        let err = McpSpec::builder("x")
505            .owner("myapp")
506            .http("https://example.com/mcp")
507            .header("Authorization", "line\nbreak")
508            .try_build()
509            .unwrap_err();
510        assert!(matches!(err, AgentConfigError::Other(_)));
511    }
512}