1use 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#[derive(Debug, Clone)]
35pub struct McpSpec {
36 pub name: String,
41
42 pub owner_tag: String,
45
46 pub transport: McpTransport,
48
49 pub friendly_name: Option<String>,
51
52 pub secret_policy: SecretPolicy,
55
56 pub adopt_unowned: bool,
63}
64
65impl McpSpec {
66 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 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 pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
89 validate_identifier(name, IdentifierKind::McpName)
90 }
91
92 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[non_exhaustive]
146pub enum SecretPolicy {
147 RefuseInlineSecretsInLocalScope,
149 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}