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