foundry_mcp/core/installation/
cursor.rs

1//! Cursor MCP server installation and management
2
3use crate::core::filesystem::write_file_atomic;
4use crate::core::installation::{
5    InstallationResult, UninstallationResult, add_server_to_config, create_cursor_server_config,
6    create_installation_result, create_uninstallation_result, get_cursor_mcp_config_path,
7    has_server_config, read_config_file, remove_server_from_config, validate_config_dir_writable,
8    write_config_file,
9};
10use crate::core::templates::ClientTemplate;
11use crate::core::templates::cursor_rules::CursorRulesTemplate;
12use crate::types::responses::EnvironmentStatus;
13use anyhow::{Context, Result};
14use std::fs;
15
16/// Install Foundry MCP server for Cursor
17pub async fn install_for_cursor() -> Result<InstallationResult> {
18    let config_path = get_cursor_mcp_config_path()?;
19    let config_path_str = config_path.to_string_lossy().to_string();
20
21    validate_config_dir_writable(config_path.as_path())?;
22
23    let mut actions_taken = Vec::new();
24
25    // Read existing configuration
26    let mut config =
27        read_config_file(&config_path).context("Failed to read existing MCP configuration")?;
28
29    // Check if server is already configured
30    let was_already_configured = has_server_config(&config, "foundry");
31
32    // Create server configuration using PATH-based 'foundry' command
33    let server_config = create_cursor_server_config();
34
35    // Add server to configuration (this will overwrite if already exists)
36    config = add_server_to_config(config, "foundry", server_config);
37
38    if was_already_configured {
39        actions_taken
40            .push("Updated existing Foundry MCP server in Cursor configuration".to_string());
41    } else {
42        actions_taken.push("Added Foundry MCP server to Cursor configuration".to_string());
43    }
44
45    // Write configuration back to file
46    write_config_file(&config_path, &config).context("Failed to write MCP configuration")?;
47    actions_taken.push(format!("Updated configuration file: {}", config_path_str));
48
49    // Validate the configuration
50    crate::core::installation::validate_config(&config)
51        .context("Configuration validation failed")?;
52    actions_taken.push("Validated MCP configuration".to_string());
53
54    // Install Cursor rules template
55    match install_cursor_rules_template(&config_path).await {
56        Ok(template_message) => {
57            actions_taken.push(template_message);
58        }
59        Err(e) => {
60            // Template installation failure is non-fatal - just log a warning
61            actions_taken.push(format!(
62                "Warning: Failed to install Cursor rules template: {}",
63                e
64            ));
65        }
66    }
67
68    Ok(create_installation_result(
69        true,
70        config_path_str,
71        actions_taken,
72    ))
73}
74
75/// Uninstall Foundry MCP server from Cursor
76pub async fn uninstall_from_cursor(remove_config: bool) -> Result<UninstallationResult> {
77    let config_path = get_cursor_mcp_config_path()?;
78    let config_path_str = config_path.to_string_lossy().to_string();
79
80    let mut actions_taken = Vec::new();
81    let mut files_removed = Vec::new();
82
83    // Read existing configuration
84    let mut config = match read_config_file(&config_path) {
85        Ok(config) => config,
86        Err(e) => return Err(e),
87    };
88
89    // Check if server is configured
90    if !has_server_config(&config, "foundry") {
91        return Err(anyhow::anyhow!(
92            "Foundry MCP server is not configured for Cursor"
93        ));
94    } else {
95        // Remove server from configuration
96        config = remove_server_from_config(config, "foundry");
97        actions_taken.push("Removed Foundry MCP server from Cursor configuration".to_string());
98    }
99
100    // Write configuration back or remove file if empty
101    if config.mcp_servers.is_empty() && remove_config {
102        if config_path.exists() {
103            std::fs::remove_file(&config_path).context("Failed to remove configuration file")?;
104            files_removed.push(config_path_str.clone());
105            actions_taken.push(format!("Removed configuration file: {}", config_path_str));
106        }
107    } else {
108        write_config_file(&config_path, &config)
109            .context("Failed to write updated MCP configuration")?;
110        actions_taken.push(format!("Updated configuration file: {}", config_path_str));
111    }
112
113    // Remove Cursor rules template
114    match remove_cursor_rules_template(&config_path).await {
115        Ok(Some(template_message)) => {
116            actions_taken.push(template_message);
117            files_removed.push("Cursor rules template".to_string());
118        }
119        Ok(None) => {
120            // Template didn't exist - that's fine
121        }
122        Err(e) => {
123            // Template removal failure is non-fatal - just log a warning
124            actions_taken.push(format!(
125                "Warning: Failed to remove Cursor rules template: {}",
126                e
127            ));
128        }
129    }
130
131    Ok(create_uninstallation_result(
132        true,
133        config_path_str,
134        actions_taken,
135        files_removed,
136    ))
137}
138
139/// Get environment status for Cursor
140pub async fn get_cursor_status(detailed: bool) -> Result<EnvironmentStatus> {
141    let config_path = get_cursor_mcp_config_path()?;
142    let config_path_str = config_path.to_string_lossy().to_string();
143
144    let mut issues = Vec::new();
145    let mut installed = false;
146    let mut config_exists = false;
147    let mut binary_accessible = false;
148    let mut config_content = None;
149
150    // Check if config file exists
151    if config_path.exists() {
152        config_exists = true;
153
154        if detailed {
155            config_content = Some(
156                std::fs::read_to_string(&config_path)
157                    .unwrap_or_else(|_| "Error reading config file".to_string()),
158            );
159        }
160    } else {
161        issues.push("MCP configuration file does not exist".to_string());
162    }
163
164    // Try to read and validate configuration
165    if config_exists {
166        match read_config_file(&config_path) {
167            Ok(config) => {
168                if has_server_config(&config, "foundry") {
169                    installed = true;
170
171                    // Validate the server configuration
172                    if let Some(server_config) =
173                        crate::core::installation::get_server_config(&config, "foundry")
174                    {
175                        // Check if binary is accessible (different logic for PATH vs absolute paths)
176                        let command_path = std::path::Path::new(&server_config.command);
177                        if command_path.is_absolute() {
178                            // For absolute paths, check if file exists
179                            binary_accessible = command_path.exists();
180                            if !binary_accessible {
181                                issues.push(format!(
182                                    "Configured binary does not exist: {}",
183                                    server_config.command
184                                ));
185                            }
186                        } else {
187                            // For PATH-based commands, assume accessible (validation happens at runtime)
188                            binary_accessible = true;
189                        }
190                    }
191                } else {
192                    issues.push("Foundry MCP server not found in configuration".to_string());
193                }
194            }
195            Err(e) => {
196                issues.push(format!("Failed to read configuration: {}", e));
197            }
198        }
199    }
200
201    Ok(EnvironmentStatus {
202        name: "cursor".to_string(),
203        installed,
204        config_path: config_path_str,
205        config_exists,
206        binary_path: if installed {
207            crate::core::installation::detect_binary_path()
208                .unwrap_or_else(|_| "unknown".to_string())
209        } else {
210            "unknown".to_string()
211        },
212        binary_accessible,
213        config_content,
214        issues,
215    })
216}
217
218/// Check if Cursor MCP configuration exists and is valid
219pub fn is_cursor_configured() -> bool {
220    get_cursor_mcp_config_path().is_ok_and(|config_path| {
221        read_config_file(&config_path).is_ok_and(|config| has_server_config(&config, "foundry"))
222    })
223}
224
225/// Install Cursor rules template
226async fn install_cursor_rules_template(config_path: &std::path::Path) -> Result<String> {
227    // Get the config directory (parent of the mcp.json file)
228    let config_dir = config_path
229        .parent()
230        .context("Failed to get config directory from config path")?;
231
232    // Get the template file path
233    let template_path = CursorRulesTemplate::file_path(config_dir)
234        .context("Failed to resolve Cursor rules template path")?;
235
236    // Create parent directory if needed
237    if let Some(parent) = template_path.parent() {
238        fs::create_dir_all(parent)
239            .with_context(|| format!("Failed to create template directory: {:?}", parent))?;
240    }
241
242    // Get the embedded template content
243    let content = CursorRulesTemplate::content();
244
245    // Write template content atomically
246    write_file_atomic(&template_path, content)
247        .with_context(|| format!("Failed to write Cursor rules template: {:?}", template_path))?;
248
249    // Return success message
250    Ok(format!(
251        "Created Cursor rules template: {}",
252        template_path.to_string_lossy()
253    ))
254}
255
256/// Remove Cursor rules template
257async fn remove_cursor_rules_template(config_path: &std::path::Path) -> Result<Option<String>> {
258    // Get the config directory (parent of the mcp.json file)
259    let config_dir = config_path
260        .parent()
261        .context("Failed to get config directory from config path")?;
262
263    // Get the template file path
264    let template_path = CursorRulesTemplate::file_path(config_dir)
265        .context("Failed to resolve Cursor rules template path")?;
266
267    // Check if template file exists
268    if !template_path.exists() {
269        return Ok(None);
270    }
271
272    // Remove the template file
273    fs::remove_file(&template_path).with_context(|| {
274        format!(
275            "Failed to remove Cursor rules template: {:?}",
276            template_path
277        )
278    })?;
279
280    // Clean up empty parent directories
281    if let Some(parent) = template_path.parent() {
282        // Only remove if directory is empty and not the config root
283        if parent.read_dir()?.next().is_none() && parent != config_dir {
284            fs::remove_dir(parent)
285                .with_context(|| format!("Failed to remove empty directory: {:?}", parent))?;
286        }
287    }
288
289    // Return success message
290    Ok(Some(format!(
291        "Removed Cursor rules template: {}",
292        template_path.to_string_lossy()
293    )))
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::test_utils::TestEnvironment;
300
301    #[test]
302    fn test_install_for_cursor_fresh_environment() {
303        let env = TestEnvironment::new().unwrap();
304
305        env.with_env_async(|| async {
306            let result = install_for_cursor().await;
307
308            assert!(
309                result.is_ok(),
310                "Install should succeed on fresh environment"
311            );
312            let install_result = result.unwrap();
313            assert!(install_result.success);
314            assert!(install_result.actions_taken.len() >= 3); // Add server, write config, validate
315
316            // Use assert_fs for rich assertions
317            assert!(
318                env.cursor_config_path().exists(),
319                "Config file should exist"
320            );
321            assert!(
322                env.cursor_config_path().is_file(),
323                "Config should be a file"
324            );
325
326            // Verify config content uses 'foundry' command from PATH
327            let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
328            assert!(config_content.contains("\"command\": \"foundry\""));
329            assert!(config_content.contains("mcpServers"));
330        });
331    }
332
333    #[test]
334    fn test_install_for_cursor_already_configured() {
335        let env = TestEnvironment::new().unwrap();
336
337        env.with_env_async(|| async {
338            // Pre-configure with existing foundry server
339            env.create_cursor_config(&[("foundry", "/old/foundry/path")])
340                .unwrap();
341
342            let result = install_for_cursor().await;
343
344            assert!(
345                result.is_ok(),
346                "Install should succeed and overwrite existing configuration"
347            );
348            let install_result = result.unwrap();
349            assert!(install_result.success);
350            assert!(
351                install_result
352                    .actions_taken
353                    .iter()
354                    .any(|action| action.contains("Updated existing Foundry MCP server"))
355            );
356        });
357    }
358
359    #[test]
360    fn test_install_for_cursor_overwrites_existing() {
361        let env = TestEnvironment::new().unwrap();
362
363        env.with_env_async(|| async {
364            // Pre-configure with existing foundry server
365            env.create_cursor_config(&[("foundry", "/old/foundry/path")])
366                .unwrap();
367
368            let result = install_for_cursor().await;
369
370            assert!(
371                result.is_ok(),
372                "Install should succeed and overwrite existing configuration"
373            );
374            let install_result = result.unwrap();
375            assert!(install_result.success);
376            assert!(
377                install_result
378                    .actions_taken
379                    .iter()
380                    .any(|action| action.contains("Updated existing Foundry MCP server"))
381            );
382
383            // Verify config was updated to use 'foundry' command from PATH
384            let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
385            assert!(config_content.contains("\"command\": \"foundry\""));
386        });
387    }
388
389    #[test]
390    fn test_install_for_cursor_config_validation() {
391        let env = TestEnvironment::new().unwrap();
392
393        env.with_env_async(|| async {
394            let result = install_for_cursor().await;
395
396            assert!(result.is_ok(), "Install should succeed and validate config");
397            let install_result = result.unwrap();
398            assert!(install_result.success);
399            assert!(
400                install_result
401                    .actions_taken
402                    .iter()
403                    .any(|action| action.contains("Validated MCP configuration"))
404            );
405        });
406    }
407
408    #[test]
409    fn test_uninstall_from_cursor_configured() {
410        let env = TestEnvironment::new().unwrap();
411
412        env.with_env_async(|| async {
413            // Pre-configure with foundry server and another server
414            env.create_cursor_config(&[
415                ("foundry", "/usr/local/bin/foundry"),
416                ("other-server", "/other/binary"),
417            ])
418            .unwrap();
419
420            let result = uninstall_from_cursor(false).await;
421
422            assert!(
423                result.is_ok(),
424                "Uninstall should succeed when foundry is configured"
425            );
426            let uninstall_result = result.unwrap();
427            assert!(uninstall_result.success);
428            assert!(
429                uninstall_result
430                    .actions_taken
431                    .iter()
432                    .any(|action| action.contains("Removed Foundry MCP server"))
433            );
434            assert!(uninstall_result.files_removed.is_empty()); // Config file should remain
435
436            // Verify foundry was removed but other server remains
437            let config_content = std::fs::read_to_string(env.cursor_config_path()).unwrap();
438            assert!(!config_content.contains("foundry"));
439            assert!(config_content.contains("other-server"));
440        });
441    }
442
443    #[test]
444    fn test_uninstall_from_cursor_not_configured() {
445        let env = TestEnvironment::new().unwrap();
446
447        env.with_env_async(|| async {
448            // Create empty config
449            env.create_cursor_config(&[]).unwrap();
450
451            let result = uninstall_from_cursor(false).await;
452
453            assert!(
454                result.is_err(),
455                "Uninstall should fail when foundry is not configured"
456            );
457            let error_msg = result.unwrap_err().to_string();
458            assert!(error_msg.contains("not configured"));
459        });
460    }
461
462    #[test]
463    fn test_uninstall_from_cursor_not_configured_fails() {
464        let env = TestEnvironment::new().unwrap();
465
466        env.with_env_async(|| async {
467            // Create empty config
468            env.create_cursor_config(&[]).unwrap();
469
470            let result = uninstall_from_cursor(false).await;
471
472            assert!(
473                result.is_err(),
474                "Uninstall should fail when foundry is not configured"
475            );
476            assert!(result.unwrap_err().to_string().contains("not configured"));
477        });
478    }
479
480    #[test]
481    fn test_uninstall_from_cursor_remove_config_when_empty() {
482        let env = TestEnvironment::new().unwrap();
483
484        env.with_env_async(|| async {
485            // Pre-configure with only foundry server
486            env.create_cursor_config(&[("foundry", "/usr/local/bin/foundry")])
487                .unwrap();
488
489            let result = uninstall_from_cursor(true).await;
490
491            assert!(
492                result.is_ok(),
493                "Uninstall should succeed and remove config when empty"
494            );
495            let uninstall_result = result.unwrap();
496            assert!(uninstall_result.success);
497            assert!(
498                uninstall_result
499                    .actions_taken
500                    .iter()
501                    .any(|action| action.contains("Removed configuration file"))
502            );
503            assert!(
504                uninstall_result
505                    .files_removed
506                    .iter()
507                    .any(|file| file.contains("mcp.json"))
508            );
509
510            // Verify config file was removed
511            assert!(!env.cursor_config_path().exists());
512        });
513    }
514
515    #[test]
516    fn test_get_cursor_status_not_installed() {
517        let env = TestEnvironment::new().unwrap();
518
519        env.with_env_async(|| async {
520            let result = get_cursor_status(false).await;
521
522            assert!(result.is_ok(), "Should be able to get Cursor status");
523            let status = result.unwrap();
524            assert_eq!(status.name, "cursor");
525            assert!(!status.installed);
526            assert!(!status.config_exists);
527            assert!(!status.binary_accessible);
528            assert!(!status.issues.is_empty());
529            assert!(
530                status
531                    .issues
532                    .iter()
533                    .any(|issue| issue.contains("does not exist"))
534            );
535        });
536    }
537
538    #[test]
539    fn test_get_cursor_status_installed() {
540        let env = TestEnvironment::new().unwrap();
541
542        env.with_env_async(|| async {
543            let binary_path = env.create_mock_binary("foundry").unwrap();
544            env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
545                .unwrap();
546
547            let result = get_cursor_status(false).await;
548
549            assert!(result.is_ok(), "Should be able to get Cursor status");
550            let status = result.unwrap();
551            assert_eq!(status.name, "cursor");
552            assert!(status.installed);
553            assert!(status.config_exists);
554            assert!(status.binary_accessible);
555            assert!(status.issues.is_empty());
556        });
557    }
558
559    #[test]
560    fn test_get_cursor_status_detailed() {
561        let env = TestEnvironment::new().unwrap();
562
563        env.with_env_async(|| async {
564            let binary_path = env.create_mock_binary("foundry").unwrap();
565            env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
566                .unwrap();
567
568            let result = get_cursor_status(true).await;
569
570            assert!(
571                result.is_ok(),
572                "Should be able to get detailed Cursor status"
573            );
574            let status = result.unwrap();
575            assert!(status.config_content.is_some());
576            let config_content = status.config_content.unwrap();
577            assert!(config_content.contains("foundry"));
578            assert!(config_content.contains("mcpServers"));
579        });
580    }
581
582    #[test]
583    fn test_get_cursor_status_invalid_config() {
584        let env = TestEnvironment::new().unwrap();
585
586        env.with_env_async(|| async {
587            // Create invalid config file
588            std::fs::create_dir_all(env.cursor_config_dir()).unwrap();
589            std::fs::write(env.cursor_config_path(), "invalid json content").unwrap();
590
591            let result = get_cursor_status(false).await;
592
593            assert!(result.is_ok(), "Should handle invalid config gracefully");
594            let status = result.unwrap();
595            assert!(!status.installed);
596            assert!(status.config_exists);
597            assert!(!status.binary_accessible);
598            assert!(
599                status
600                    .issues
601                    .iter()
602                    .any(|issue| issue.contains("Failed to read configuration"))
603            );
604        });
605    }
606
607    #[test]
608    fn test_get_cursor_status_missing_binary() {
609        let env = TestEnvironment::new().unwrap();
610
611        env.with_env_async(|| async {
612            // Configure with non-existent binary path
613            env.create_cursor_config(&[("foundry", "/nonexistent/foundry")])
614                .unwrap();
615
616            let result = get_cursor_status(false).await;
617
618            assert!(result.is_ok(), "Should handle missing binary gracefully");
619            let status = result.unwrap();
620            assert!(status.installed);
621            assert!(status.config_exists);
622            assert!(!status.binary_accessible);
623            assert!(
624                status
625                    .issues
626                    .iter()
627                    .any(|issue| issue.contains("does not exist"))
628            );
629        });
630    }
631
632    #[test]
633    fn test_is_cursor_configured() {
634        let env = TestEnvironment::new().unwrap();
635
636        env.with_env_async(|| async {
637            // Initially not configured
638            assert!(!is_cursor_configured());
639
640            // Configure with foundry server
641            let binary_path = env.create_mock_binary("foundry").unwrap();
642            env.create_cursor_config(&[("foundry", &binary_path.to_string_lossy())])
643                .unwrap();
644
645            // Now should be configured
646            assert!(is_cursor_configured());
647        });
648    }
649
650    #[test]
651    fn test_binary_path_validation() {
652        let env = TestEnvironment::new().unwrap();
653        let binary_path = env.create_mock_binary("foundry").unwrap();
654
655        // Test binary path validation (this should succeed)
656        let binary_path_str = binary_path.to_string_lossy().to_string();
657        let validation_result = crate::core::installation::validate_binary_path(&binary_path_str);
658        assert!(
659            validation_result.is_ok(),
660            "Binary path validation should succeed for valid path"
661        );
662
663        // Test with invalid binary path (this should fail)
664        let invalid_result = crate::core::installation::validate_binary_path("/nonexistent/path");
665        assert!(
666            invalid_result.is_err(),
667            "Binary path validation should fail for invalid path"
668        );
669    }
670
671    #[test]
672    fn test_config_validation() {
673        let env = TestEnvironment::new().unwrap();
674        let binary_path = env.create_mock_binary("foundry").unwrap();
675
676        // Create a config with valid foundry server
677        let mut config = crate::core::installation::json_config::McpConfig {
678            mcp_servers: std::collections::HashMap::new(),
679        };
680        let server_config =
681            crate::core::installation::create_server_config(&binary_path.to_string_lossy());
682        config = crate::core::installation::add_server_to_config(config, "foundry", server_config);
683
684        // This should pass validation since the binary exists
685        let result = crate::core::installation::validate_config(&config);
686        assert!(result.is_ok());
687
688        // Test with invalid binary path
689        let mut bad_config = crate::core::installation::json_config::McpConfig {
690            mcp_servers: std::collections::HashMap::new(),
691        };
692        let bad_server_config =
693            crate::core::installation::create_server_config("/nonexistent/path");
694        bad_config = crate::core::installation::add_server_to_config(
695            bad_config,
696            "foundry",
697            bad_server_config,
698        );
699
700        let result = crate::core::installation::validate_config(&bad_config);
701        assert!(result.is_err());
702        assert!(
703            result
704                .unwrap_err()
705                .to_string()
706                .contains("command does not exist")
707        );
708    }
709}