1use std::collections::HashMap;
9use std::net::TcpStream;
10use std::path::Path;
11use std::process::Command;
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use ferridriver::backend::BackendKind;
16use ferridriver::state::ConnectMode;
17use serde::{Deserialize, Serialize};
18
19pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
21
22pub const DISCOVER_TCP_TIMEOUT: Duration = Duration::from_millis(500);
24
25pub const DEFAULT_SERVER_NAME: &str = "ferridriver";
27
28#[derive(Debug, Default, Deserialize, Serialize)]
31#[serde(default, rename_all = "camelCase")]
32pub struct McpConfig {
33 pub server: ServerConfig,
35 pub browser: BrowserConfig,
37
38 #[serde(skip)]
41 command_cache: CommandCache,
42 #[serde(skip)]
44 instructions_cache: std::sync::OnceLock<String>,
45}
46
47#[derive(Debug, Default, Deserialize, Serialize)]
49#[serde(default)]
50pub struct ServerConfig {
51 pub name: Option<String>,
53 pub instructions: Option<String>,
55 pub extra_instructions: Option<String>,
58}
59
60#[derive(Debug, Default, Deserialize, Serialize)]
62#[serde(default)]
63pub struct BrowserConfig {
64 pub backend: Option<String>,
66 pub headless: Option<bool>,
68 pub executable_path: Option<String>,
70 pub viewport: Option<ViewportDef>,
72 pub chrome_args: Vec<String>,
74 pub instance_args_command: Option<String>,
78 pub instance_discover_command: Option<String>,
82 pub command_cache_ttl: Option<u64>,
84 pub instances: HashMap<String, InstanceConfig>,
86 pub default_instance: Option<InstanceConfig>,
88}
89
90#[derive(Debug, Default, Clone, Deserialize, Serialize)]
92#[serde(default)]
93pub struct InstanceConfig {
94 pub chrome_args: Vec<String>,
96 pub connect_url: Option<String>,
98 pub discover_profile: Option<String>,
101}
102
103#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct ViewportDef {
106 pub width: Option<i64>,
107 pub height: Option<i64>,
108}
109
110impl McpConfig {
111 #[must_use]
113 pub fn backend_kind(&self) -> BackendKind {
114 match self.browser.backend.as_deref() {
115 Some("cdp-raw") => BackendKind::CdpRaw,
116 Some("bidi") => BackendKind::Bidi,
117 #[cfg(target_os = "macos")]
118 Some("webkit") => BackendKind::WebKit,
119 _ => BackendKind::CdpPipe,
120 }
121 }
122
123 #[must_use]
125 pub fn headless(&self) -> bool {
126 self.browser.headless.unwrap_or(false)
127 }
128
129 fn cache_ttl(&self) -> Duration {
131 self
132 .browser
133 .command_cache_ttl
134 .map_or(DEFAULT_CACHE_TTL, Duration::from_secs)
135 }
136
137 #[must_use]
139 pub fn chrome_args(&self) -> Vec<String> {
140 self.browser.chrome_args.clone()
141 }
142
143 #[must_use]
146 pub fn chrome_args_for_instance(&self, instance: &str) -> Vec<String> {
147 let mut args = Vec::new();
148
149 if let Some(ic) = self.browser.instances.get(instance) {
150 args.extend(ic.chrome_args.iter().cloned());
151 } else if let Some(ref default) = self.browser.default_instance {
152 args.extend(default.chrome_args.iter().cloned());
153 }
154
155 if let Some(ref cmd_template) = self.browser.instance_args_command {
156 let cmd = cmd_template.replace("${INSTANCE}", instance);
157 match self.command_cache.get_or_exec(&cmd, self.cache_ttl()) {
158 Ok(lines) => args.extend(lines),
159 Err(e) => tracing::warn!("instance_args_command failed for '{instance}': {e}"),
160 }
161 }
162
163 args
164 }
165
166 #[must_use]
169 pub fn resolve_instance(&self, instance: &str) -> Option<ConnectMode> {
170 if let Some(ic) = self.browser.instances.get(instance) {
171 if let Some(ref url) = ic.connect_url {
172 return Some(ConnectMode::ConnectUrl(url.clone()));
173 }
174 if let Some(ref profile_template) = ic.discover_profile {
175 match discover_from_profile(profile_template, instance) {
176 ProfileDiscovery::Found(mode) => return Some(mode),
177 ProfileDiscovery::Stale => return None,
178 ProfileDiscovery::NotFound => {},
179 }
180 }
181 }
182
183 if let Some(ref default) = self.browser.default_instance {
184 if let Some(ref profile_template) = default.discover_profile {
185 match discover_from_profile(profile_template, instance) {
186 ProfileDiscovery::Found(mode) => return Some(mode),
187 ProfileDiscovery::Stale => return None,
188 ProfileDiscovery::NotFound => {},
189 }
190 }
191 }
192
193 if let Some(ref cmd_template) = self.browser.instance_discover_command {
194 let cmd = cmd_template.replace("${INSTANCE}", instance);
195 match self.command_cache.get_or_exec(&cmd, self.cache_ttl()) {
196 Ok(lines) => {
197 if let Some(url) = lines.first() {
198 let url = url.trim();
199 if url.starts_with("ws://") || url.starts_with("wss://") {
200 return Some(ConnectMode::ConnectUrl(url.to_string()));
201 }
202 }
203 },
204 Err(e) => tracing::warn!("instance_discover_command failed for '{instance}': {e}"),
205 }
206 }
207
208 None
209 }
210
211 #[must_use]
213 pub fn server_name(&self) -> &str {
214 self.server.name.as_deref().unwrap_or(DEFAULT_SERVER_NAME)
215 }
216
217 pub fn server_instructions<'a>(&'a self, defaults: &str) -> &'a str {
220 self.instructions_cache.get_or_init(|| {
221 if let Some(ref full) = self.server.instructions {
222 return full.clone();
223 }
224 match &self.server.extra_instructions {
225 Some(extra) => format!("{defaults}\n\n{extra}"),
226 None => defaults.to_string(),
227 }
228 })
229 }
230}
231
232enum ProfileDiscovery {
234 Found(ConnectMode),
235 Stale,
236 NotFound,
237}
238
239fn discover_from_profile(profile_template: &str, instance: &str) -> ProfileDiscovery {
242 let template = profile_template.replace("${INSTANCE}", instance);
243 let expanded = shellexpand::tilde(&template);
244 let profile_dir = Path::new(expanded.as_ref());
245
246 let port_file = profile_dir.join("DevToolsActivePort");
247 let Ok(content) = std::fs::read_to_string(&port_file) else {
248 return ProfileDiscovery::NotFound;
249 };
250 let mut lines = content.lines();
251 let Some(port) = lines.next().and_then(|l| l.parse::<u16>().ok()) else {
252 return ProfileDiscovery::NotFound;
253 };
254 let path = lines.next().unwrap_or("/");
255
256 let addr = format!("127.0.0.1:{port}");
257 if let Ok(sock_addr) = addr.parse() {
258 if TcpStream::connect_timeout(&sock_addr, DISCOVER_TCP_TIMEOUT).is_ok() {
259 return ProfileDiscovery::Found(ConnectMode::ConnectUrl(format!("ws://127.0.0.1:{port}{path}")));
260 }
261 }
262
263 ProfileDiscovery::Stale
264}
265
266#[derive(Debug, Default)]
271struct CommandCache {
272 entries: Mutex<HashMap<String, CacheEntry>>,
273}
274
275#[derive(Debug, Clone)]
276struct CacheEntry {
277 lines: Vec<String>,
278 created: Instant,
279}
280
281impl CommandCache {
282 fn get_or_exec(&self, command: &str, ttl: Duration) -> Result<Vec<String>, String> {
283 {
284 let cache = self.entries.lock().map_err(|e| format!("Cache lock poisoned: {e}"))?;
285 if let Some(entry) = cache.get(command) {
286 if entry.created.elapsed() < ttl {
287 return Ok(entry.lines.clone());
288 }
289 }
290 }
291
292 let lines = exec_command(command)?;
293
294 {
295 let mut cache = self.entries.lock().map_err(|e| format!("Cache lock poisoned: {e}"))?;
296 cache.insert(
297 command.to_string(),
298 CacheEntry {
299 lines: lines.clone(),
300 created: Instant::now(),
301 },
302 );
303 }
304
305 Ok(lines)
306 }
307}
308
309fn exec_command(command: &str) -> Result<Vec<String>, String> {
317 let output = Command::new("sh")
318 .args(["-c", command])
319 .output()
320 .map_err(|e| format!("Failed to execute command: {e}"))?;
321
322 if !output.status.success() {
323 let stderr = String::from_utf8_lossy(&output.stderr);
324 return Err(format!("Command failed (exit {}): {stderr}", output.status));
325 }
326
327 let stdout = String::from_utf8_lossy(&output.stdout);
328 let trimmed = stdout.trim();
329
330 if trimmed.is_empty() {
331 return Ok(Vec::new());
332 }
333
334 if trimmed.starts_with('[')
335 && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
336 {
337 return Ok(arr);
338 }
339
340 if trimmed.starts_with('{')
341 && let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
342 && let Some(arr) = value.get("args").and_then(|v| v.as_array())
343 {
344 let strs: Option<Vec<String>> = arr.iter().map(|v| v.as_str().map(str::to_string)).collect();
345 if let Some(strs) = strs {
346 return Ok(strs);
347 }
348 }
349
350 Ok(
351 trimmed
352 .lines()
353 .map(|l| l.trim().to_string())
354 .filter(|l| !l.is_empty())
355 .collect(),
356 )
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 const TEST_DEFAULTS: &str = "Browser automation via the Model Context Protocol.";
364
365 #[test]
366 fn default_config_has_sane_defaults() {
367 let config = McpConfig::default();
368 assert_eq!(config.server_name(), "ferridriver");
369 assert_eq!(config.server_instructions(TEST_DEFAULTS), TEST_DEFAULTS);
370 assert!(config.chrome_args().is_empty());
371 assert!(config.chrome_args_for_instance("dev").is_empty());
372 assert!(config.resolve_instance("dev").is_none());
373 assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
374 assert!(!config.headless());
375 }
376
377 #[test]
378 fn instructions_override() {
379 let mut config = McpConfig::default();
380 config.server.instructions = Some("Custom only".into());
381 config.server.extra_instructions = Some("Should be ignored".into());
382 assert_eq!(config.server_instructions(TEST_DEFAULTS), "Custom only");
383 }
384
385 #[test]
386 fn extra_instructions_appended() {
387 let mut config = McpConfig::default();
388 config.server.extra_instructions = Some("Extra context here.".into());
389 let instructions = config.server_instructions(TEST_DEFAULTS);
390 assert!(instructions.starts_with(TEST_DEFAULTS));
391 assert!(instructions.ends_with("Extra context here."));
392 }
393
394 #[test]
395 fn static_instance_args() {
396 let mut config = McpConfig::default();
397 config.browser.instances.insert(
398 "staging".into(),
399 InstanceConfig {
400 chrome_args: vec!["--proxy-server=localhost:8080".into()],
401 ..Default::default()
402 },
403 );
404 assert_eq!(
405 config.chrome_args_for_instance("staging"),
406 vec!["--proxy-server=localhost:8080"]
407 );
408 assert!(config.chrome_args_for_instance("unknown").is_empty());
409 }
410
411 #[test]
412 fn default_instance_fallback() {
413 let mut config = McpConfig::default();
414 config.browser.default_instance = Some(InstanceConfig {
415 chrome_args: vec!["--default-flag".into()],
416 ..Default::default()
417 });
418 assert_eq!(config.chrome_args_for_instance("any"), vec!["--default-flag"]);
419 }
420
421 #[test]
422 fn static_connect_url() {
423 let mut config = McpConfig::default();
424 config.browser.instances.insert(
425 "remote".into(),
426 InstanceConfig {
427 connect_url: Some("ws://192.168.1.50:9222/devtools/browser/abc".into()),
428 ..Default::default()
429 },
430 );
431 let mode = config.resolve_instance("remote");
432 assert!(matches!(mode, Some(ConnectMode::ConnectUrl(url)) if url.contains("192.168.1.50")));
433 }
434
435 #[test]
436 fn backend_parsing() {
437 let mut config = McpConfig::default();
438 assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
439 config.browser.backend = Some("cdp-raw".into());
440 assert_eq!(config.backend_kind(), BackendKind::CdpRaw);
441 config.browser.backend = Some("bidi".into());
442 assert_eq!(config.backend_kind(), BackendKind::Bidi);
443 config.browser.backend = Some("unknown".into());
444 assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
445 }
446
447 #[test]
448 fn command_cache_returns_cached_value() {
449 let cache = CommandCache::default();
450 let result1 = cache.get_or_exec("echo hello", Duration::from_secs(60));
451 assert_eq!(
452 result1.as_ref().map(Vec::as_slice),
453 Ok(["hello".to_string()].as_slice())
454 );
455 let result2 = cache.get_or_exec("echo hello", Duration::from_secs(60));
456 assert_eq!(result1, result2);
457 }
458
459 #[test]
460 fn command_json_output_parsing() {
461 let result = exec_command(r#"echo '["--flag1", "--flag2"]'"#);
462 assert_eq!(result, Ok(vec!["--flag1".to_string(), "--flag2".to_string()]));
463 }
464
465 #[test]
466 fn command_line_output_parsing() {
467 let result = exec_command("echo flag1 && echo flag2");
468 assert_eq!(result, Ok(vec!["flag1".to_string(), "flag2".to_string()]));
469 }
470
471 #[test]
472 fn command_empty_output() {
473 let result = exec_command("echo ''");
474 assert_eq!(result, Ok(Vec::new()));
475 }
476
477 #[test]
478 fn instance_args_command_substitutes_instance_name() {
479 let mut config = McpConfig::default();
480 config.browser.instance_args_command = Some("echo '--user-agent=Test-${INSTANCE}'".into());
481 let args = config.chrome_args_for_instance("staging");
482 assert_eq!(args, vec!["--user-agent=Test-staging"]);
483 let args2 = config.chrome_args_for_instance("production");
484 assert_eq!(args2, vec!["--user-agent=Test-production"]);
485 }
486
487 #[test]
488 fn instance_args_command_json_output() {
489 let mut config = McpConfig::default();
490 config.browser.instance_args_command = Some(r#"printf '["--dns-prefetch-disable","--tag=dev"]'"#.into());
491 let args = config.chrome_args_for_instance("dev");
492 assert_eq!(args, vec!["--dns-prefetch-disable", "--tag=dev"]);
493 }
494
495 #[test]
496 fn instance_args_command_json_object_with_args_field() {
497 let mut config = McpConfig::default();
498 config.browser.instance_args_command = Some(
499 r#"printf '{"environment":"staging","args":["--no-first-run","--host-resolver-rules=MAP a.box.com 1.2.3.4"]}'"#
500 .into(),
501 );
502 let args = config.chrome_args_for_instance("staging");
503 assert_eq!(
504 args,
505 vec!["--no-first-run", "--host-resolver-rules=MAP a.box.com 1.2.3.4"]
506 );
507 }
508
509 #[test]
510 fn instance_args_command_merges_with_static_args() {
511 let mut config = McpConfig::default();
512 config.browser.instances.insert(
513 "staging".into(),
514 InstanceConfig {
515 chrome_args: vec!["--proxy-server=localhost:8080".into()],
516 ..Default::default()
517 },
518 );
519 config.browser.instance_args_command = Some("echo '--user-agent=Bot-${INSTANCE}'".into());
520
521 let args = config.chrome_args_for_instance("staging");
522 assert_eq!(args.len(), 2);
523 assert_eq!(args[0], "--proxy-server=localhost:8080");
524 assert_eq!(args[1], "--user-agent=Bot-staging");
525 }
526
527 #[test]
528 fn instance_args_command_default_instance_plus_command() {
529 let mut config = McpConfig::default();
530 config.browser.default_instance = Some(InstanceConfig {
531 chrome_args: vec!["--default-flag".into()],
532 ..Default::default()
533 });
534 config.browser.instance_args_command = Some("echo '--dynamic-flag'".into());
535 let args = config.chrome_args_for_instance("unknown-env");
536 assert_eq!(args, vec!["--default-flag", "--dynamic-flag"]);
537 }
538
539 #[test]
540 fn instance_args_command_failure_is_non_fatal() {
541 let mut config = McpConfig::default();
542 config.browser.instance_args_command = Some("false".into());
543 config.browser.instances.insert(
544 "dev".into(),
545 InstanceConfig {
546 chrome_args: vec!["--static-flag".into()],
547 ..Default::default()
548 },
549 );
550 let args = config.chrome_args_for_instance("dev");
551 assert_eq!(args, vec!["--static-flag"]);
552 }
553
554 #[test]
555 fn discover_command_returns_ws_url() {
556 let mut config = McpConfig::default();
557 config.browser.instance_discover_command = Some("echo 'ws://127.0.0.1:9222/devtools/browser/abc'".into());
558 let mode = config.resolve_instance("any");
559 assert!(matches!(
560 mode,
561 Some(ConnectMode::ConnectUrl(url)) if url == "ws://127.0.0.1:9222/devtools/browser/abc"
562 ));
563 }
564
565 #[test]
566 fn discover_command_substitutes_instance() {
567 let mut config = McpConfig::default();
568 config.browser.instance_discover_command = Some("echo 'ws://127.0.0.1:9222/${INSTANCE}'".into());
569 let mode = config.resolve_instance("staging");
570 assert!(matches!(
571 mode,
572 Some(ConnectMode::ConnectUrl(url)) if url == "ws://127.0.0.1:9222/staging"
573 ));
574 }
575
576 #[test]
577 fn discover_command_ignores_non_ws_output() {
578 let mut config = McpConfig::default();
579 config.browser.instance_discover_command = Some("echo 'not-a-ws-url'".into());
580 assert!(config.resolve_instance("dev").is_none());
581 }
582
583 #[test]
584 fn discover_command_empty_output_returns_none() {
585 let mut config = McpConfig::default();
586 config.browser.instance_discover_command = Some("echo ''".into());
587 assert!(config.resolve_instance("dev").is_none());
588 }
589
590 #[test]
591 fn discover_command_failure_returns_none() {
592 let mut config = McpConfig::default();
593 config.browser.instance_discover_command = Some("false".into());
594 assert!(config.resolve_instance("dev").is_none());
595 }
596
597 #[test]
598 fn static_connect_url_takes_priority_over_discover_command() {
599 let mut config = McpConfig::default();
600 config.browser.instances.insert(
601 "staging".into(),
602 InstanceConfig {
603 connect_url: Some("ws://static-host:9222/browser".into()),
604 ..Default::default()
605 },
606 );
607 config.browser.instance_discover_command = Some("echo 'ws://dynamic-host:9222/browser'".into());
608 let mode = config.resolve_instance("staging");
609 assert!(matches!(
610 mode,
611 Some(ConnectMode::ConnectUrl(url)) if url == "ws://static-host:9222/browser"
612 ));
613 }
614
615 #[test]
616 fn unknown_instance_falls_through_to_discover_command() {
617 let mut config = McpConfig::default();
618 config.browser.instances.insert(
619 "staging".into(),
620 InstanceConfig {
621 connect_url: Some("ws://staging-host:9222/browser".into()),
622 ..Default::default()
623 },
624 );
625 config.browser.instance_discover_command = Some("echo 'ws://discovered-host:9222/${INSTANCE}'".into());
626
627 let staging = config.resolve_instance("staging");
628 assert!(matches!(
629 staging,
630 Some(ConnectMode::ConnectUrl(url)) if url.contains("staging-host")
631 ));
632
633 let prod = config.resolve_instance("production");
634 assert!(matches!(
635 prod,
636 Some(ConnectMode::ConnectUrl(url)) if url == "ws://discovered-host:9222/production"
637 ));
638 }
639
640 #[test]
641 fn no_discovery_returns_none_for_launch_fallback() {
642 let config = McpConfig::default();
643 assert!(config.resolve_instance("anything").is_none());
644 }
645
646 #[test]
647 fn command_cache_ttl_respects_config() {
648 let mut config = McpConfig::default();
649 config.browser.command_cache_ttl = Some(60);
650 assert_eq!(config.cache_ttl(), Duration::from_secs(60));
651
652 config.browser.command_cache_ttl = None;
653 assert_eq!(config.cache_ttl(), DEFAULT_CACHE_TTL);
654 }
655
656 #[test]
657 fn command_cache_expires_after_ttl() {
658 let cache = CommandCache::default();
659 let short_ttl = Duration::from_millis(50);
660
661 let result1 = cache.get_or_exec("echo first", short_ttl);
662 assert!(result1.is_ok());
663
664 std::thread::sleep(Duration::from_millis(100));
665
666 let result2 = cache.get_or_exec("echo first", short_ttl);
667 assert_eq!(result1, result2);
668
669 let entries = cache.entries.lock().unwrap();
670 let entry = entries.get("echo first").unwrap();
671 assert!(entry.created.elapsed() < Duration::from_millis(50));
672 }
673
674 #[test]
675 fn command_cache_different_commands_cached_separately() {
676 let cache = CommandCache::default();
677 let ttl = Duration::from_secs(60);
678
679 let r1 = cache.get_or_exec("echo aaa", ttl).unwrap();
680 let r2 = cache.get_or_exec("echo bbb", ttl).unwrap();
681 assert_eq!(r1, vec!["aaa"]);
682 assert_eq!(r2, vec!["bbb"]);
683
684 let entries = cache.entries.lock().unwrap();
685 assert_eq!(entries.len(), 2);
686 }
687
688 #[test]
689 fn config_resolve_uses_instance_not_composite_key() {
690 let mut config = McpConfig::default();
691 config.browser.instances.insert(
692 "staging".into(),
693 InstanceConfig {
694 connect_url: Some("ws://staging-browser:9222".into()),
695 ..Default::default()
696 },
697 );
698 assert!(config.resolve_instance("staging").is_some());
699 assert!(config.resolve_instance("staging:admin").is_none());
700 }
701
702 #[test]
703 fn instance_args_uses_instance_not_composite_key() {
704 let mut config = McpConfig::default();
705 config.browser.instances.insert(
706 "staging".into(),
707 InstanceConfig {
708 chrome_args: vec!["--staging-flag".into()],
709 ..Default::default()
710 },
711 );
712 assert_eq!(config.chrome_args_for_instance("staging"), vec!["--staging-flag"]);
713 assert!(config.chrome_args_for_instance("staging:admin").is_empty());
714 }
715
716 #[test]
717 fn discover_profile_nonexistent_path_returns_none() {
718 let result = discover_from_profile("/nonexistent/path/${INSTANCE}/profile", "dev");
719 assert!(matches!(result, ProfileDiscovery::NotFound));
720 }
721
722 #[test]
723 fn discover_profile_stale_port_file_returns_some_none() {
724 let dir = std::env::temp_dir().join("ferridriver-config-test-stale-profile");
725 let _ = std::fs::create_dir_all(&dir);
726 std::fs::write(dir.join("DevToolsActivePort"), "59999\n/devtools/browser/fake").unwrap();
727
728 let result = discover_from_profile(dir.to_str().unwrap(), "dev");
729 let _ = std::fs::remove_dir_all(&dir);
730
731 assert!(matches!(result, ProfileDiscovery::Stale));
732 }
733
734 #[test]
735 fn discover_profile_instance_substitution() {
736 let dir = std::env::temp_dir().join("ferridriver-config-test-inst-sub");
737 let staging_dir = dir.join("staging");
738 let _ = std::fs::create_dir_all(&staging_dir);
739 std::fs::write(staging_dir.join("DevToolsActivePort"), "59998\n/devtools/browser/abc").unwrap();
740
741 let template = format!("{}/${{INSTANCE}}", dir.display());
742 let result = discover_from_profile(&template, "staging");
743 let _ = std::fs::remove_dir_all(&dir);
744
745 assert!(matches!(result, ProfileDiscovery::Stale));
746 }
747}