Skip to main content

mars_agents/target/
cursor.rs

1/// `.cursor` target adapter.
2///
3/// Handles MCP server registration for the Cursor IDE.
4///
5/// Cursor-native lowering:
6/// - MCP: writes to `mcp.json` (mcpServers section), env vars as `${env:VAR}` syntax
7/// - Hooks: dropped — Cursor has limited/undocumented hook surface (lossiness: dropped)
8use std::path::{Path, PathBuf};
9
10use crate::diagnostic::{DiagnosticCategory, DiagnosticCollector};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14
15use super::{ConfigEntry, McpServerEntry, TargetAdapter};
16
17#[derive(Debug)]
18pub struct CursorAdapter;
19
20impl TargetAdapter for CursorAdapter {
21    fn name(&self) -> &str {
22        ".cursor"
23    }
24
25    fn skill_variant_key(&self) -> Option<&str> {
26        Some("cursor")
27    }
28
29    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
30        match kind {
31            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
32            _ => None,
33        }
34    }
35
36    fn emit_pre_write_diagnostics(
37        &self,
38        entries: &[ConfigEntry],
39        diag: &mut crate::diagnostic::DiagnosticCollector,
40    ) {
41        CursorAdapter::emit_hook_lossiness_diagnostics(entries, diag);
42    }
43
44    fn write_config_entries(
45        &self,
46        entries: &[ConfigEntry],
47        target_dir: &Path,
48    ) -> Result<Vec<PathBuf>, MarsError> {
49        let mcp_servers: Vec<&McpServerEntry> = entries
50            .iter()
51            .filter_map(|e| {
52                if let ConfigEntry::McpServer(s) = e {
53                    Some(s)
54                } else {
55                    None
56                }
57            })
58            .collect();
59
60        // Hooks are dropped for Cursor — no native hook surface.
61        // Callers should have already emitted lossiness diagnostics for dropped hooks.
62
63        if mcp_servers.is_empty() {
64            return Ok(Vec::new());
65        }
66
67        let path = write_cursor_mcp_json(target_dir, &mcp_servers)?;
68        Ok(vec![path])
69    }
70
71    fn remove_config_entries(
72        &self,
73        entry_keys: &[String],
74        target_dir: &Path,
75    ) -> Result<(), MarsError> {
76        remove_cursor_mcp_entries(entry_keys, target_dir)
77    }
78}
79
80impl CursorAdapter {
81    /// Emit diagnostics for any hook entries in `entries` (all dropped for Cursor).
82    ///
83    /// Called by the compiler before `write_config_entries` so that the
84    /// lossiness is reported even though hooks are silently skipped in write.
85    pub fn emit_hook_lossiness_diagnostics(
86        entries: &[ConfigEntry],
87        diag: &mut DiagnosticCollector,
88    ) {
89        for entry in entries {
90            if let ConfigEntry::Hook(hook) = entry {
91                diag.warn_with_category(
92                    "hook-dropped",
93                    format!(
94                        "hook `{}` (event `{}`) dropped for target `.cursor` — \
95                         Cursor has no native hook support",
96                        hook.name, hook.event
97                    ),
98                    DiagnosticCategory::Lossiness,
99                );
100            }
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Cursor MCP — `mcp.json` format
107// ---------------------------------------------------------------------------
108//
109// Cursor uses `${env:VAR_NAME}` interpolation syntax for env vars.
110// {
111//   "mcpServers": {
112//     "server-name": {
113//       "command": "...",
114//       "args": [...],
115//       "env": { "KEY": "${env:VAR_NAME}" }
116//     }
117//   }
118// }
119
120fn write_cursor_mcp_json(
121    target_dir: &Path,
122    servers: &[&McpServerEntry],
123) -> Result<PathBuf, MarsError> {
124    let path = target_dir.join("mcp.json");
125
126    let mut root: serde_json::Value = if path.is_file() {
127        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
128        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
129    } else {
130        serde_json::json!({})
131    };
132
133    let mcp_obj = root
134        .as_object_mut()
135        .ok_or_else(|| {
136            MarsError::Config(crate::error::ConfigError::Invalid {
137                message: format!("{} is not a JSON object", path.display()),
138            })
139        })?
140        .entry("mcpServers")
141        .or_insert_with(|| serde_json::json!({}));
142
143    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
144        MarsError::Config(crate::error::ConfigError::Invalid {
145            message: format!("{}: mcpServers is not an object", path.display()),
146        })
147    })?;
148
149    for server in servers {
150        let mut entry = serde_json::json!({
151            "command": server.command,
152            "args": server.args,
153        });
154
155        // Cursor env: `${env:VAR_NAME}` interpolation syntax.
156        if !server.env.is_empty() {
157            let env_obj: serde_json::Map<String, serde_json::Value> = server
158                .env
159                .iter()
160                .map(|(k, v)| {
161                    (
162                        k.clone(),
163                        serde_json::Value::String(format!("${{env:{v}}}")),
164                    )
165                })
166                .collect();
167            entry["env"] = serde_json::Value::Object(env_obj);
168        }
169
170        mcp_map.insert(server.name.clone(), entry);
171    }
172
173    let content = serde_json::to_string_pretty(&root).map_err(|e| {
174        MarsError::Config(crate::error::ConfigError::Invalid {
175            message: format!("failed to serialize {}: {e}", path.display()),
176        })
177    })?;
178    crate::fs::atomic_write(&path, content.as_bytes())?;
179
180    Ok(path)
181}
182
183fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
184    let path = target_dir.join("mcp.json");
185    if !path.is_file() {
186        return Ok(());
187    }
188
189    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
190    let mut root: serde_json::Value =
191        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
192
193    if let Some(mcp_map) = root
194        .as_object_mut()
195        .and_then(|o| o.get_mut("mcpServers"))
196        .and_then(|v| v.as_object_mut())
197    {
198        for key in entry_keys {
199            if let Some(name) = key.strip_prefix("mcp:") {
200                mcp_map.remove(name);
201            }
202        }
203    }
204
205    let content = serde_json::to_string_pretty(&root).map_err(|e| {
206        MarsError::Config(crate::error::ConfigError::Invalid {
207            message: format!("failed to serialize {}: {e}", path.display()),
208        })
209    })?;
210    crate::fs::atomic_write(&path, content.as_bytes())?;
211    Ok(())
212}
213
214// ---------------------------------------------------------------------------
215// Tests
216// ---------------------------------------------------------------------------
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::target::{HookEntry, McpServerEntry};
222    use indexmap::IndexMap;
223    use tempfile::TempDir;
224
225    fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
226        let mut env = IndexMap::new();
227        if let Some((k, v)) = env_var {
228            env.insert(k.to_string(), v.to_string());
229        }
230        ConfigEntry::McpServer(McpServerEntry {
231            name: name.to_string(),
232            command: "npx".to_string(),
233            args: vec![],
234            env,
235        })
236    }
237
238    fn make_hook_entry(name: &str) -> ConfigEntry {
239        ConfigEntry::Hook(HookEntry {
240            name: name.to_string(),
241            event: "tool.pre".to_string(),
242            native_event: "PreToolUse".to_string(),
243            script_path: "/hooks/run.sh".to_string(),
244            order: 0,
245        })
246    }
247
248    #[test]
249    fn write_mcp_creates_mcp_json() {
250        let tmp = TempDir::new().unwrap();
251        let adapter = CursorAdapter;
252        let entries = vec![make_mcp_entry("context7", None)];
253        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
254        assert_eq!(written.len(), 1);
255        assert!(tmp.path().join("mcp.json").exists());
256
257        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
258        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
259        assert!(json["mcpServers"]["context7"].is_object());
260    }
261
262    #[test]
263    fn write_mcp_env_uses_cursor_interpolation() {
264        let tmp = TempDir::new().unwrap();
265        let adapter = CursorAdapter;
266        let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
267        adapter.write_config_entries(&entries, tmp.path()).unwrap();
268
269        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
270        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
271        // Cursor uses ${env:VAR_NAME} interpolation syntax
272        assert_eq!(
273            json["mcpServers"]["server"]["env"]["API_KEY"],
274            "${env:MY_SECRET}"
275        );
276    }
277
278    #[test]
279    fn write_hooks_dropped_no_file_written() {
280        let tmp = TempDir::new().unwrap();
281        let adapter = CursorAdapter;
282        let entries = vec![make_hook_entry("audit")];
283        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
284        // Hooks are dropped — no file written.
285        assert!(written.is_empty());
286        assert!(!tmp.path().join("settings.json").exists());
287    }
288
289    #[test]
290    fn hook_lossiness_emits_diagnostic() {
291        let entries = vec![make_hook_entry("audit")];
292        let mut diag = crate::diagnostic::DiagnosticCollector::with_lossiness_mode(
293            crate::diagnostic::LossinessMode::Surface,
294        );
295        CursorAdapter::emit_hook_lossiness_diagnostics(&entries, &mut diag);
296        let collected = diag.drain();
297        assert_eq!(collected.len(), 1);
298        assert!(collected[0].message.contains("dropped"));
299        assert_eq!(
300            collected[0].category,
301            Some(crate::diagnostic::DiagnosticCategory::Lossiness)
302        );
303    }
304
305    #[test]
306    fn remove_mcp_entries_preserves_others() {
307        let tmp = TempDir::new().unwrap();
308        let adapter = CursorAdapter;
309        let entries = vec![
310            make_mcp_entry("to-remove", None),
311            make_mcp_entry("to-keep", None),
312        ];
313        adapter.write_config_entries(&entries, tmp.path()).unwrap();
314
315        adapter
316            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
317            .unwrap();
318
319        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
320        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
321        assert!(json["mcpServers"]["to-remove"].is_null());
322        assert!(json["mcpServers"]["to-keep"].is_object());
323    }
324}