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::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(
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                );
99            }
100        }
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Cursor MCP — `mcp.json` format
106// ---------------------------------------------------------------------------
107//
108// Cursor uses `${env:VAR_NAME}` interpolation syntax for env vars.
109// {
110//   "mcpServers": {
111//     "server-name": {
112//       "command": "...",
113//       "args": [...],
114//       "env": { "KEY": "${env:VAR_NAME}" }
115//     }
116//   }
117// }
118
119fn write_cursor_mcp_json(
120    target_dir: &Path,
121    servers: &[&McpServerEntry],
122) -> Result<PathBuf, MarsError> {
123    let path = target_dir.join("mcp.json");
124
125    let mut root: serde_json::Value = if path.is_file() {
126        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
127        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
128    } else {
129        serde_json::json!({})
130    };
131
132    let mcp_obj = root
133        .as_object_mut()
134        .ok_or_else(|| {
135            MarsError::Config(crate::error::ConfigError::Invalid {
136                message: format!("{} is not a JSON object", path.display()),
137            })
138        })?
139        .entry("mcpServers")
140        .or_insert_with(|| serde_json::json!({}));
141
142    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
143        MarsError::Config(crate::error::ConfigError::Invalid {
144            message: format!("{}: mcpServers is not an object", path.display()),
145        })
146    })?;
147
148    for server in servers {
149        let mut entry = serde_json::json!({
150            "command": server.command,
151            "args": server.args,
152        });
153
154        // Cursor env: `${env:VAR_NAME}` interpolation syntax.
155        if !server.env.is_empty() {
156            let env_obj: serde_json::Map<String, serde_json::Value> = server
157                .env
158                .iter()
159                .map(|(k, v)| {
160                    (
161                        k.clone(),
162                        serde_json::Value::String(format!("${{env:{v}}}")),
163                    )
164                })
165                .collect();
166            entry["env"] = serde_json::Value::Object(env_obj);
167        }
168
169        mcp_map.insert(server.name.clone(), entry);
170    }
171
172    let content = serde_json::to_string_pretty(&root).map_err(|e| {
173        MarsError::Config(crate::error::ConfigError::Invalid {
174            message: format!("failed to serialize {}: {e}", path.display()),
175        })
176    })?;
177    crate::fs::atomic_write(&path, content.as_bytes())?;
178
179    Ok(path)
180}
181
182fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
183    let path = target_dir.join("mcp.json");
184    if !path.is_file() {
185        return Ok(());
186    }
187
188    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
189    let mut root: serde_json::Value =
190        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
191
192    if let Some(mcp_map) = root
193        .as_object_mut()
194        .and_then(|o| o.get_mut("mcpServers"))
195        .and_then(|v| v.as_object_mut())
196    {
197        for key in entry_keys {
198            if let Some(name) = key.strip_prefix("mcp:") {
199                mcp_map.remove(name);
200            }
201        }
202    }
203
204    let content = serde_json::to_string_pretty(&root).map_err(|e| {
205        MarsError::Config(crate::error::ConfigError::Invalid {
206            message: format!("failed to serialize {}: {e}", path.display()),
207        })
208    })?;
209    crate::fs::atomic_write(&path, content.as_bytes())?;
210    Ok(())
211}
212
213// ---------------------------------------------------------------------------
214// Tests
215// ---------------------------------------------------------------------------
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::target::{HookEntry, McpServerEntry};
221    use indexmap::IndexMap;
222    use tempfile::TempDir;
223
224    fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
225        let mut env = IndexMap::new();
226        if let Some((k, v)) = env_var {
227            env.insert(k.to_string(), v.to_string());
228        }
229        ConfigEntry::McpServer(McpServerEntry {
230            name: name.to_string(),
231            command: "npx".to_string(),
232            args: vec![],
233            env,
234        })
235    }
236
237    fn make_hook_entry(name: &str) -> ConfigEntry {
238        ConfigEntry::Hook(HookEntry {
239            name: name.to_string(),
240            event: "tool.pre".to_string(),
241            native_event: "PreToolUse".to_string(),
242            script_path: "/hooks/run.sh".to_string(),
243            order: 0,
244        })
245    }
246
247    #[test]
248    fn write_mcp_creates_mcp_json() {
249        let tmp = TempDir::new().unwrap();
250        let adapter = CursorAdapter;
251        let entries = vec![make_mcp_entry("context7", None)];
252        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
253        assert_eq!(written.len(), 1);
254        assert!(tmp.path().join("mcp.json").exists());
255
256        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
257        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
258        assert!(json["mcpServers"]["context7"].is_object());
259    }
260
261    #[test]
262    fn write_mcp_env_uses_cursor_interpolation() {
263        let tmp = TempDir::new().unwrap();
264        let adapter = CursorAdapter;
265        let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
266        adapter.write_config_entries(&entries, tmp.path()).unwrap();
267
268        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
269        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
270        // Cursor uses ${env:VAR_NAME} interpolation syntax
271        assert_eq!(
272            json["mcpServers"]["server"]["env"]["API_KEY"],
273            "${env:MY_SECRET}"
274        );
275    }
276
277    #[test]
278    fn write_hooks_dropped_no_file_written() {
279        let tmp = TempDir::new().unwrap();
280        let adapter = CursorAdapter;
281        let entries = vec![make_hook_entry("audit")];
282        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
283        // Hooks are dropped — no file written.
284        assert!(written.is_empty());
285        assert!(!tmp.path().join("settings.json").exists());
286    }
287
288    #[test]
289    fn hook_lossiness_emits_diagnostic() {
290        let entries = vec![make_hook_entry("audit")];
291        let mut diag = crate::diagnostic::DiagnosticCollector::new();
292        CursorAdapter::emit_hook_lossiness_diagnostics(&entries, &mut diag);
293        let collected = diag.drain();
294        assert_eq!(collected.len(), 1);
295        assert!(collected[0].message.contains("dropped"));
296    }
297
298    #[test]
299    fn remove_mcp_entries_preserves_others() {
300        let tmp = TempDir::new().unwrap();
301        let adapter = CursorAdapter;
302        let entries = vec![
303            make_mcp_entry("to-remove", None),
304            make_mcp_entry("to-keep", None),
305        ];
306        adapter.write_config_entries(&entries, tmp.path()).unwrap();
307
308        adapter
309            .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
310            .unwrap();
311
312        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
313        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
314        assert!(json["mcpServers"]["to-remove"].is_null());
315        assert!(json["mcpServers"]["to-keep"].is_object());
316    }
317}