1use anyhow::{Context, Result, bail};
6use mcp_execution_core::{ServerConfig, ServerConfigBuilder, ServerId};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11#[derive(Debug, Deserialize)]
13#[serde(rename_all = "camelCase")]
14struct McpConfig {
15 mcp_servers: HashMap<String, McpServerConfig>,
16}
17
18#[derive(Debug, Deserialize)]
20struct McpServerConfig {
21 command: String,
22 #[serde(default)]
23 args: Vec<String>,
24 #[serde(default)]
25 env: HashMap<String, String>,
26}
27
28fn load_mcp_config() -> Result<McpConfig> {
37 let home = dirs::home_dir().context("failed to get home directory")?;
38 let config_path = home.join(".claude").join("mcp.json");
39
40 let content = std::fs::read_to_string(&config_path)
41 .with_context(|| "failed to read MCP config from ~/.claude/mcp.json")?;
42
43 let config: McpConfig =
44 serde_json::from_str(&content).context("failed to parse MCP config JSON")?;
45
46 Ok(config)
47}
48
49pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
74 let config = load_mcp_config()?;
75
76 let server_config = config.mcp_servers.get(name).with_context(|| {
77 format!(
78 "server '{name}' not found in MCP config at ~/.claude/mcp.json\n\
79 Hint: Use 'mcp-execution-cli server list' to see available servers"
80 )
81 })?;
82
83 let id = ServerId::new(name);
84 let mut builder = ServerConfig::builder().command(server_config.command.clone());
85
86 if !server_config.args.is_empty() {
87 builder = builder.args(server_config.args.clone());
88 }
89
90 for (key, value) in &server_config.env {
91 builder = builder.env(key.clone(), value.clone());
92 }
93
94 Ok((id, builder.build()))
95}
96
97pub fn build_server_config(
140 server: Option<String>,
141 args: Vec<String>,
142 env: Vec<String>,
143 cwd: Option<String>,
144 http: Option<String>,
145 sse: Option<String>,
146 headers: Vec<String>,
147) -> Result<(ServerId, ServerConfig)> {
148 let parse_key_value = |s: &str, kind: &str| -> Result<(String, String)> {
150 let parts: Vec<&str> = s.splitn(2, '=').collect();
151 if parts.len() != 2 {
152 bail!("invalid {kind} format: '{s}' (expected KEY=VALUE)");
153 }
154 if parts[0].is_empty() {
155 bail!("invalid {kind} format: '{s}' (key cannot be empty)");
156 }
157 Ok((parts[0].to_string(), parts[1].to_string()))
158 };
159
160 let (server_id, config) = if let Some(url) = http {
162 let id = ServerId::new(&url);
164 let mut builder = ServerConfig::builder().http_transport(url);
165
166 for header in headers {
167 let (key, value) = parse_key_value(&header, "header")?;
168 builder = builder.header(key, value);
169 }
170
171 (id, builder.build())
172 } else if let Some(url) = sse {
173 let id = ServerId::new(&url);
175 let mut builder = ServerConfig::builder().sse_transport(url);
176
177 for header in headers {
178 let (key, value) = parse_key_value(&header, "header")?;
179 builder = builder.header(key, value);
180 }
181
182 (id, builder.build())
183 } else {
184 let command = server.expect("server is required for stdio transport");
186 let id = ServerId::new(&command);
187 let mut builder: ServerConfigBuilder = ServerConfig::builder().command(command);
188
189 if !args.is_empty() {
190 builder = builder.args(args);
191 }
192
193 for env_var in env {
194 let (key, value) = parse_key_value(&env_var, "environment variable")?;
195 builder = builder.env(key, value);
196 }
197
198 if let Some(dir) = cwd {
199 builder = builder.cwd(PathBuf::from(dir));
200 }
201
202 (id, builder.build())
203 };
204
205 Ok((server_id, config))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_build_server_config_stdio() {
214 let (id, config) = build_server_config(
215 Some("github-mcp-server".to_string()),
216 vec!["stdio".to_string()],
217 vec!["TOKEN=abc123".to_string()],
218 None,
219 None,
220 None,
221 vec![],
222 )
223 .unwrap();
224
225 assert_eq!(id.as_str(), "github-mcp-server");
226 assert_eq!(config.command(), "github-mcp-server");
227 assert_eq!(config.args(), &["stdio"]);
228 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
229 }
230
231 #[test]
232 fn test_build_server_config_docker() {
233 let (id, config) = build_server_config(
234 Some("docker".to_string()),
235 vec![
236 "run".to_string(),
237 "-i".to_string(),
238 "--rm".to_string(),
239 "ghcr.io/github/github-mcp-server".to_string(),
240 ],
241 vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx".to_string()],
242 None,
243 None,
244 None,
245 vec![],
246 )
247 .unwrap();
248
249 assert_eq!(id.as_str(), "docker");
250 assert_eq!(config.command(), "docker");
251 assert_eq!(
252 config.args(),
253 &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
254 );
255 assert_eq!(
256 config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
257 Some(&"ghp_xxx".to_string())
258 );
259 }
260
261 #[test]
262 fn test_build_server_config_http() {
263 let (id, config) = build_server_config(
264 None,
265 vec![],
266 vec![],
267 None,
268 Some("https://api.githubcopilot.com/mcp/".to_string()),
269 None,
270 vec!["Authorization=Bearer token123".to_string()],
271 )
272 .unwrap();
273
274 assert_eq!(id.as_str(), "https://api.githubcopilot.com/mcp/");
275 assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
276 assert_eq!(
277 config.headers().get("Authorization"),
278 Some(&"Bearer token123".to_string())
279 );
280 }
281
282 #[test]
283 fn test_build_server_config_sse() {
284 let (id, config) = build_server_config(
285 None,
286 vec![],
287 vec![],
288 None,
289 None,
290 Some("https://example.com/sse".to_string()),
291 vec!["X-API-Key=secret".to_string()],
292 )
293 .unwrap();
294
295 assert_eq!(id.as_str(), "https://example.com/sse");
296 assert_eq!(config.url(), Some("https://example.com/sse"));
297 assert_eq!(
298 config.headers().get("X-API-Key"),
299 Some(&"secret".to_string())
300 );
301 }
302
303 #[test]
304 fn test_build_server_config_with_cwd() {
305 let (_, config) = build_server_config(
306 Some("server".to_string()),
307 vec![],
308 vec![],
309 Some("/tmp/workdir".to_string()),
310 None,
311 None,
312 vec![],
313 )
314 .unwrap();
315
316 assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
317 }
318
319 #[test]
320 fn test_build_server_config_invalid_env() {
321 let result = build_server_config(
322 Some("server".to_string()),
323 vec![],
324 vec!["INVALID_FORMAT".to_string()],
325 None,
326 None,
327 None,
328 vec![],
329 );
330
331 assert!(result.is_err());
332 assert!(
333 result
334 .unwrap_err()
335 .to_string()
336 .contains("expected KEY=VALUE")
337 );
338 }
339
340 #[test]
341 fn test_build_server_config_invalid_header() {
342 let result = build_server_config(
343 None,
344 vec![],
345 vec![],
346 None,
347 Some("https://example.com".to_string()),
348 None,
349 vec!["InvalidHeader".to_string()],
350 );
351
352 assert!(result.is_err());
353 assert!(
354 result
355 .unwrap_err()
356 .to_string()
357 .contains("expected KEY=VALUE")
358 );
359 }
360
361 #[test]
362 fn test_build_server_config_multiple_env_vars() {
363 let (_, config) = build_server_config(
364 Some("server".to_string()),
365 vec![],
366 vec![
367 "TOKEN=abc123".to_string(),
368 "API_KEY=secret456".to_string(),
369 "DEBUG=true".to_string(),
370 ],
371 None,
372 None,
373 None,
374 vec![],
375 )
376 .unwrap();
377
378 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
379 assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
380 assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
381 assert_eq!(config.env().len(), 3);
382 }
383
384 #[test]
385 fn test_build_server_config_env_with_special_chars() {
386 let (_, config) = build_server_config(
388 Some("server".to_string()),
389 vec![],
390 vec![
391 "TOKEN=abc=def=123".to_string(),
392 "URL=https://example.com?key=value".to_string(),
393 "ENCODED=a=b=c=d".to_string(),
394 ],
395 None,
396 None,
397 None,
398 vec![],
399 )
400 .unwrap();
401
402 assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
403 assert_eq!(
404 config.env().get("URL"),
405 Some(&"https://example.com?key=value".to_string())
406 );
407 assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
408 }
409
410 #[test]
411 fn test_build_server_config_empty_args_stdio() {
412 let (id, config) = build_server_config(
413 Some("simple-server".to_string()),
414 vec![],
415 vec![],
416 None,
417 None,
418 None,
419 vec![],
420 )
421 .unwrap();
422
423 assert_eq!(id.as_str(), "simple-server");
424 assert_eq!(config.command(), "simple-server");
425 assert!(config.args().is_empty());
426 assert!(config.env().is_empty());
427 }
428
429 #[test]
430 fn test_build_server_config_http_multiple_headers() {
431 let (_, config) = build_server_config(
432 None,
433 vec![],
434 vec![],
435 None,
436 Some("https://api.example.com".to_string()),
437 None,
438 vec![
439 "Authorization=Bearer token123".to_string(),
440 "X-API-Key=secret".to_string(),
441 "Content-Type=application/json".to_string(),
442 ],
443 )
444 .unwrap();
445
446 assert_eq!(
447 config.headers().get("Authorization"),
448 Some(&"Bearer token123".to_string())
449 );
450 assert_eq!(
451 config.headers().get("X-API-Key"),
452 Some(&"secret".to_string())
453 );
454 assert_eq!(
455 config.headers().get("Content-Type"),
456 Some(&"application/json".to_string())
457 );
458 assert_eq!(config.headers().len(), 3);
459 }
460
461 #[test]
462 fn test_build_server_config_header_with_special_chars() {
463 let (_, config) = build_server_config(
465 None,
466 vec![],
467 vec![],
468 None,
469 Some("https://api.example.com".to_string()),
470 None,
471 vec![
472 "X-Custom=value=with=equals".to_string(),
473 "X-Query=a=b&c=d".to_string(),
474 ],
475 )
476 .unwrap();
477
478 assert_eq!(
479 config.headers().get("X-Custom"),
480 Some(&"value=with=equals".to_string())
481 );
482 assert_eq!(
483 config.headers().get("X-Query"),
484 Some(&"a=b&c=d".to_string())
485 );
486 }
487
488 #[test]
489 fn test_build_server_config_sse_with_headers() {
490 let (id, config) = build_server_config(
491 None,
492 vec![],
493 vec![],
494 None,
495 None,
496 Some("https://sse.example.com/events".to_string()),
497 vec!["Authorization=Bearer xyz".to_string()],
498 )
499 .unwrap();
500
501 assert_eq!(id.as_str(), "https://sse.example.com/events");
502 assert_eq!(config.url(), Some("https://sse.example.com/events"));
503 assert_eq!(
504 config.headers().get("Authorization"),
505 Some(&"Bearer xyz".to_string())
506 );
507 }
508
509 #[test]
510 fn test_build_server_config_empty_value_in_env() {
511 let (_, config) = build_server_config(
513 Some("server".to_string()),
514 vec![],
515 vec!["EMPTY=".to_string()],
516 None,
517 None,
518 None,
519 vec![],
520 )
521 .unwrap();
522
523 assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
524 }
525
526 #[test]
527 fn test_build_server_config_empty_value_in_header() {
528 let (_, config) = build_server_config(
530 None,
531 vec![],
532 vec![],
533 None,
534 Some("https://example.com".to_string()),
535 None,
536 vec!["X-Empty=".to_string()],
537 )
538 .unwrap();
539
540 assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
541 }
542
543 #[test]
544 fn test_build_server_config_complex_docker_scenario() {
545 let (id, config) = build_server_config(
546 Some("docker".to_string()),
547 vec![
548 "run".to_string(),
549 "-i".to_string(),
550 "--rm".to_string(),
551 "--network=host".to_string(),
552 "my-image:latest".to_string(),
553 ],
554 vec![
555 "API_TOKEN=secret123".to_string(),
556 "LOG_LEVEL=debug".to_string(),
557 ],
558 Some("/app/workdir".to_string()),
559 None,
560 None,
561 vec![],
562 )
563 .unwrap();
564
565 assert_eq!(id.as_str(), "docker");
566 assert_eq!(config.command(), "docker");
567 assert_eq!(
568 config.args(),
569 &["run", "-i", "--rm", "--network=host", "my-image:latest"]
570 );
571 assert_eq!(
572 config.env().get("API_TOKEN"),
573 Some(&"secret123".to_string())
574 );
575 assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
576 assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
577 }
578
579 #[test]
580 fn test_build_server_config_empty_key_in_env() {
581 let result = build_server_config(
582 Some("server".to_string()),
583 vec![],
584 vec!["=value".to_string()],
585 None,
586 None,
587 None,
588 vec![],
589 );
590
591 assert!(result.is_err());
592 assert!(
593 result
594 .unwrap_err()
595 .to_string()
596 .contains("key cannot be empty")
597 );
598 }
599
600 #[test]
601 fn test_build_server_config_empty_key_in_header() {
602 let result = build_server_config(
603 None,
604 vec![],
605 vec![],
606 None,
607 Some("https://example.com".to_string()),
608 None,
609 vec!["=value".to_string()],
610 );
611
612 assert!(result.is_err());
613 assert!(
614 result
615 .unwrap_err()
616 .to_string()
617 .contains("key cannot be empty")
618 );
619 }
620
621 #[test]
622 fn test_load_server_from_config_not_found() {
623 let result = load_server_from_config("nonexistent");
625
626 assert!(result.is_err());
628 }
629
630 #[test]
631 fn test_load_mcp_config_no_file() {
632 let result = load_mcp_config();
634
635 if let Err(error) = result {
638 let error = error.to_string();
639 assert!(
640 error.contains("failed to read MCP config")
641 || error.contains("failed to get home directory"),
642 "Expected config read error or home dir error, got: {error}"
643 );
644 }
645 }
646}