Skip to main content

agent_config/spec/mcp/
builder.rs

1//! Fluent builder for [`McpSpec`].
2
3use std::collections::BTreeMap;
4
5use crate::error::AgentConfigError;
6
7use super::transport::McpTransport;
8use super::{McpSpec, SecretPolicy};
9
10/// Builder for [`McpSpec`].
11#[derive(Debug, Clone)]
12pub struct McpSpecBuilder {
13    pub(super) name: String,
14    pub(super) owner_tag: Option<String>,
15    pub(super) transport: Option<McpTransport>,
16    pub(super) friendly_name: Option<String>,
17    pub(super) secret_policy: SecretPolicy,
18    pub(super) adopt_unowned: bool,
19    pub(super) builder_error: Option<String>,
20}
21
22impl McpSpecBuilder {
23    /// Set the consumer's owner tag (recorded in the sidecar ownership ledger).
24    pub fn owner(mut self, tag: impl Into<String>) -> Self {
25        self.owner_tag = Some(tag.into());
26        self
27    }
28
29    /// Adopt a config entry that exists on disk but has no recorded owner.
30    ///
31    /// Use this to recover from a crash between an earlier install's config
32    /// write and ledger record. With `false` (default) such entries are
33    /// refused with [`AgentConfigError::NotOwnedByCaller`] (`actual: None`)
34    /// to avoid silently taking over a hand-installed entry.
35    pub fn adopt_unowned(mut self, adopt: bool) -> Self {
36        self.adopt_unowned = adopt;
37        self
38    }
39
40    /// Configure a stdio launcher.
41    pub fn stdio<I, S>(mut self, command: impl Into<String>, args: I) -> Self
42    where
43        I: IntoIterator<Item = S>,
44        S: Into<String>,
45    {
46        self.transport = Some(McpTransport::Stdio {
47            command: command.into(),
48            args: args.into_iter().map(Into::into).collect(),
49            env: BTreeMap::new(),
50        });
51        self
52    }
53
54    /// Configure an HTTP transport.
55    pub fn http(mut self, url: impl Into<String>) -> Self {
56        self.transport = Some(McpTransport::Http {
57            url: url.into(),
58            headers: BTreeMap::new(),
59        });
60        self
61    }
62
63    /// Configure an SSE transport.
64    pub fn sse(mut self, url: impl Into<String>) -> Self {
65        self.transport = Some(McpTransport::Sse {
66            url: url.into(),
67            headers: BTreeMap::new(),
68        });
69        self
70    }
71
72    /// Set or replace one environment variable on a stdio transport.
73    ///
74    /// Calling this before configuring stdio, or after configuring a non-stdio
75    /// transport, records a builder error returned by
76    /// [`try_build`](Self::try_build).
77    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
78        match &mut self.transport {
79            Some(McpTransport::Stdio { env, .. }) => {
80                env.insert(key.into(), value.into());
81            }
82            Some(McpTransport::Http { .. }) | Some(McpTransport::Sse { .. }) => {
83                self.record_builder_error("env() can only be used with stdio MCP transports");
84            }
85            None => {
86                self.record_builder_error("env() called before stdio transport was configured");
87            }
88        }
89        self
90    }
91
92    /// Set an env variable to a placeholder that references the host
93    /// environment, e.g. `GITHUB_TOKEN=${GITHUB_TOKEN}`.
94    ///
95    /// Placeholders are not treated as inline secrets by the local-scope
96    /// secret policy because the actual secret value is not written.
97    pub fn env_from_host(mut self, key: impl Into<String>) -> Self {
98        let key = key.into();
99        let placeholder = format!("${{{key}}}");
100        self = self.env(key, placeholder);
101        self
102    }
103
104    /// Set an env variable to a caller-provided placeholder.
105    ///
106    /// This is useful when a harness supports its own placeholder syntax. The
107    /// value is still validated as a normal MCP env value.
108    pub fn env_placeholder(
109        mut self,
110        key: impl Into<String>,
111        placeholder: impl Into<String>,
112    ) -> Self {
113        self = self.env(key, placeholder);
114        self
115    }
116
117    /// Set or replace one header on an HTTP/SSE transport.
118    ///
119    /// Calling this before configuring HTTP/SSE, or after configuring stdio,
120    /// records a builder error returned by [`try_build`](Self::try_build).
121    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
122        match &mut self.transport {
123            Some(McpTransport::Http { headers, .. }) | Some(McpTransport::Sse { headers, .. }) => {
124                headers.insert(key.into(), value.into());
125            }
126            Some(McpTransport::Stdio { .. }) => {
127                self.record_builder_error("header() can only be used with HTTP/SSE MCP transports");
128            }
129            None => {
130                self.record_builder_error(
131                    "header() called before HTTP/SSE transport was configured",
132                );
133            }
134        }
135        self
136    }
137
138    /// Set a human-friendly display name.
139    pub fn friendly_name(mut self, name: impl Into<String>) -> Self {
140        self.friendly_name = Some(name.into());
141        self
142    }
143
144    /// Explicitly allow likely secret env values in project-local MCP configs.
145    ///
146    /// The default policy refuses this because local project config files are
147    /// easy to commit, sync, or share accidentally.
148    pub fn allow_local_inline_secrets(mut self) -> Self {
149        self.secret_policy = SecretPolicy::AllowInlineSecretsInLocalScope;
150        self
151    }
152
153    /// Finalize the spec, panicking on missing or invalid fields.
154    ///
155    /// Convenience wrapper around [`try_build()`](Self::try_build) for tests
156    /// and examples. Production code should prefer [`try_build()`](Self::try_build)
157    /// to propagate errors instead of panicking.
158    ///
159    /// # Panics
160    ///
161    /// Panics if required fields are missing or validation fails.
162    pub fn build(self) -> McpSpec {
163        self.try_build().expect("McpSpec missing required field")
164    }
165
166    /// Finalize the spec, returning [`Result`] on missing or invalid fields.
167    ///
168    /// This is the recommended way to build a spec in production code.
169    /// See [crate-level documentation](crate#production-usage) for a full example.
170    ///
171    /// # Errors
172    ///
173    /// - [`AgentConfigError::Other`] when an earlier builder method recorded
174    ///   a deferred error (e.g. invalid env var name).
175    /// - [`AgentConfigError::MissingSpecField`] with `field = "owner"` or
176    ///   `field = "transport"` when those calls were skipped.
177    /// - [`AgentConfigError::InvalidTag`] when `name`, `owner_tag`, or any
178    ///   transport field (command path, env var, header value) fails
179    ///   identifier or transport validation.
180    pub fn try_build(self) -> Result<McpSpec, AgentConfigError> {
181        if let Some(error) = self.builder_error {
182            return Err(AgentConfigError::Other(anyhow::anyhow!(error)));
183        }
184        let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
185            id: "<mcp builder>",
186            field: "owner",
187        })?;
188        let transport = self.transport.ok_or(AgentConfigError::MissingSpecField {
189            id: "<mcp builder>",
190            field: "transport",
191        })?;
192        let spec = McpSpec {
193            name: self.name,
194            owner_tag,
195            transport,
196            friendly_name: self.friendly_name,
197            secret_policy: self.secret_policy,
198            adopt_unowned: self.adopt_unowned,
199        };
200        spec.validate()?;
201        Ok(spec)
202    }
203
204    fn record_builder_error(&mut self, message: &'static str) {
205        if self.builder_error.is_none() {
206            self.builder_error = Some(message.to_string());
207        }
208    }
209}