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///
8use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, HookFragmentMode, McpServerEntry, TargetAdapter};
15
16#[derive(Debug)]
17pub struct CursorAdapter;
18
19impl TargetAdapter for CursorAdapter {
20    fn name(&self) -> &str {
21        ".cursor"
22    }
23
24    fn known_hook_events(&self) -> Option<&'static [&'static str]> {
25        // cursor-agent 2026.07.16 installed-binary validator, extracted and
26        // verified by p5714 on 2026-07-24; p5695 provided the docs baseline.
27        Some(&[
28            "beforeShellExecution",
29            "beforeMCPExecution",
30            "afterShellExecution",
31            "afterMCPExecution",
32            "beforeReadFile",
33            "afterFileEdit",
34            "beforeTabFileRead",
35            "afterTabFileEdit",
36            "stop",
37            "beforeSubmitPrompt",
38            "afterAgentResponse",
39            "afterAgentThought",
40            "sessionStart",
41            "sessionEnd",
42            "preCompact",
43            "subagentStart",
44            "subagentStop",
45            "preToolUse",
46            "postToolUse",
47            "postToolUseFailure",
48            "workspaceOpen",
49        ])
50    }
51
52    fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
53        Some(HookFragmentMode::MergeJson)
54    }
55
56    fn skill_variant_key(&self) -> Option<&str> {
57        Some("cursor")
58    }
59
60    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
61        match kind {
62            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
63            _ => None,
64        }
65    }
66
67    fn write_config_entries(
68        &self,
69        write: crate::surface_ownership::retention::ConfigWrite<'_>,
70        project_root: &Path,
71    ) -> Result<Vec<PathBuf>, MarsError> {
72        let (target_dir, entries) = write.into_parts(project_root);
73        let mcp_servers: Vec<&McpServerEntry> = entries
74            .iter()
75            .filter_map(|e| {
76                if let ConfigEntry::McpServer(s) = e {
77                    Some(s)
78                } else {
79                    None
80                }
81            })
82            .collect();
83        let hooks: Vec<&HookEntry> = entries
84            .iter()
85            .filter_map(|entry| match entry {
86                ConfigEntry::Hook(hook) => Some(hook),
87                ConfigEntry::McpServer(_) => None,
88            })
89            .collect();
90
91        let mut written = Vec::new();
92        if !mcp_servers.is_empty() {
93            written.push((write_cursor_mcp_json)(&target_dir, &mcp_servers)?);
94        }
95        if !hooks.is_empty() {
96            written.push((write_cursor_hooks_json)(&target_dir, &hooks)?);
97        }
98        Ok(written)
99    }
100
101    fn mcp_config_file_names(&self) -> &'static [&'static str] {
102        &["mcp.json"]
103    }
104    fn hook_config_file_names(&self) -> &'static [&'static str] {
105        &["hooks.json"]
106    }
107
108    fn remove_owned_hook_entries(
109        &self,
110        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
111        project_root: &Path,
112        diag: &mut crate::diagnostic::DiagnosticCollector,
113    ) -> crate::surface_ownership::retention::RemovalReport {
114        let (target_dir, removal) = operation.into_parts(project_root);
115        match remove_owned_cursor_hooks(&removal.prior_records, &target_dir, diag) {
116            Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
117            Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
118                error,
119                removal.prior_records.clone(),
120            ),
121        }
122    }
123
124    fn remove_config_entries(
125        &self,
126        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
127        project_root: &Path,
128    ) -> crate::surface_ownership::retention::RemovalReport {
129        let (target_dir, removal) = operation.into_parts(project_root);
130        match remove_cursor_mcp_entries(&removal.keys_to_remove, &target_dir) {
131            Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
132            Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
133                error,
134                removal.prior_records.clone(),
135            ),
136        }
137    }
138}
139
140// Cursor uses a flat entry array and requires the version wrapper.
141fn write_cursor_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
142    let path = target_dir.join("hooks.json");
143    let mut root: serde_json::Value = if path.is_file() {
144        super::parse_json_file(&path)?
145    } else {
146        serde_json::json!({})
147    };
148    let root_object = root.as_object_mut().ok_or_else(|| {
149        MarsError::Config(crate::error::ConfigError::Invalid {
150            message: format!("{} is not a JSON object", path.display()),
151        })
152    })?;
153    root_object.insert("version".into(), serde_json::json!(1));
154    let hooks_map = root_object
155        .entry("hooks")
156        .or_insert_with(|| serde_json::json!({}))
157        .as_object_mut()
158        .ok_or_else(|| {
159            MarsError::Config(crate::error::ConfigError::Invalid {
160                message: format!("{}: hooks is not an object", path.display()),
161            })
162        })?;
163    for hook in hooks {
164        super::append_json_event_entries(hooks_map, &hook.native_event, &hook.entries, &path)?;
165    }
166    crate::fs::atomic_write(
167        &path,
168        serde_json::to_string_pretty(&root)
169            .map_err(|error| {
170                MarsError::Config(crate::error::ConfigError::Invalid {
171                    message: format!("failed to serialize {}: {error}", path.display()),
172                })
173            })?
174            .as_bytes(),
175    )?;
176    Ok(path)
177}
178
179fn remove_owned_cursor_hooks(
180    records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
181    target_dir: &Path,
182    diag: &mut crate::diagnostic::DiagnosticCollector,
183) -> Result<(), MarsError> {
184    let path = target_dir.join("hooks.json");
185    if !path.is_file() {
186        return Ok(());
187    }
188    let mut root = super::parse_json_file(&path)?;
189    let mut changed = false;
190    if let Some(hooks_map) = root
191        .get_mut("hooks")
192        .and_then(serde_json::Value::as_object_mut)
193    {
194        for (key, record) in records {
195            let Some((event, name)) = key
196                .strip_prefix("hook:")
197                .and_then(|rest| rest.split_once(':'))
198            else {
199                continue;
200            };
201            let Some(expected) = record
202                .emitted_json
203                .as_deref()
204                .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(json).ok())
205            else {
206                continue;
207            };
208            let update = super::remove_json_event_entries(hooks_map, event, &expected);
209            changed |= update.changed;
210            if update.missing > 0 {
211                diag.warn(
212                    "config-divergence",
213                    format!(
214                        "config-divergence: managed hook `{name}` diverged in target `.cursor` at `{}`; preserving edited config and appending the package entry",
215                        path.display()
216                    ),
217                );
218            }
219        }
220    }
221    if changed
222        && root
223            .get("hooks")
224            .and_then(serde_json::Value::as_object)
225            .is_some_and(serde_json::Map::is_empty)
226    {
227        root.as_object_mut().unwrap().remove("hooks");
228    }
229    if !changed {
230        return Ok(());
231    }
232    crate::fs::atomic_write(
233        &path,
234        serde_json::to_string_pretty(&root)
235            .map_err(|error| {
236                MarsError::Config(crate::error::ConfigError::Invalid {
237                    message: format!("failed to serialize {}: {error}", path.display()),
238                })
239            })?
240            .as_bytes(),
241    )
242}
243
244// ---------------------------------------------------------------------------
245// Cursor MCP — `mcp.json` format
246// ---------------------------------------------------------------------------
247//
248// Cursor uses `${env:VAR_NAME}` interpolation syntax for env vars.
249// {
250//   "mcpServers": {
251//     "server-name": {
252//       "command": "...",
253//       "args": [...],
254//       "env": { "KEY": "${env:VAR_NAME}" }
255//     }
256//   }
257// }
258
259fn write_cursor_mcp_json(
260    target_dir: &Path,
261    servers: &[&McpServerEntry],
262) -> Result<PathBuf, MarsError> {
263    let path = target_dir.join("mcp.json");
264
265    let mut root: serde_json::Value = if path.is_file() {
266        super::parse_json_file(&path)?
267    } else {
268        serde_json::json!({})
269    };
270
271    let mcp_obj = root
272        .as_object_mut()
273        .ok_or_else(|| {
274            MarsError::Config(crate::error::ConfigError::Invalid {
275                message: format!("{} is not a JSON object", path.display()),
276            })
277        })?
278        .entry("mcpServers")
279        .or_insert_with(|| serde_json::json!({}));
280
281    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
282        MarsError::Config(crate::error::ConfigError::Invalid {
283            message: format!("{}: mcpServers is not an object", path.display()),
284        })
285    })?;
286
287    for server in servers {
288        let mut entry = serde_json::json!({
289            "command": server.command,
290            "args": server.args,
291        });
292
293        // Cursor env: `${env:VAR_NAME}` interpolation syntax.
294        if !server.env.is_empty() {
295            let env_obj: serde_json::Map<String, serde_json::Value> = server
296                .env
297                .iter()
298                .map(|(k, v)| {
299                    (
300                        k.clone(),
301                        serde_json::Value::String(format!("${{env:{v}}}")),
302                    )
303                })
304                .collect();
305            entry["env"] = serde_json::Value::Object(env_obj);
306        }
307
308        mcp_map.insert(server.name.clone(), entry);
309    }
310
311    let content = serde_json::to_string_pretty(&root).map_err(|e| {
312        MarsError::Config(crate::error::ConfigError::Invalid {
313            message: format!("failed to serialize {}: {e}", path.display()),
314        })
315    })?;
316    crate::fs::atomic_write(&path, content.as_bytes())?;
317
318    Ok(path)
319}
320
321fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
322    let path = target_dir.join("mcp.json");
323    if !path.is_file() {
324        return Ok(());
325    }
326
327    let mut root = super::parse_json_file(&path)?;
328
329    if let Some(mcp_map) = root
330        .as_object_mut()
331        .and_then(|o| o.get_mut("mcpServers"))
332        .and_then(|v| v.as_object_mut())
333    {
334        for key in entry_keys {
335            if let Some(name) = key.strip_prefix("mcp:") {
336                mcp_map.remove(name);
337            }
338        }
339    }
340
341    let content = serde_json::to_string_pretty(&root).map_err(|e| {
342        MarsError::Config(crate::error::ConfigError::Invalid {
343            message: format!("failed to serialize {}: {e}", path.display()),
344        })
345    })?;
346    crate::fs::atomic_write(&path, content.as_bytes())?;
347    Ok(())
348}
349
350// ---------------------------------------------------------------------------
351// Tests
352// ---------------------------------------------------------------------------
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::surface_ownership::retention::WritePermit;
358
359    fn write_permit(entries: &[ConfigEntry]) -> WritePermit<'static> {
360        WritePermit::for_test("", entries[0].surface())
361    }
362    use crate::target::McpServerEntry;
363    use indexmap::IndexMap;
364    use tempfile::TempDir;
365
366    fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
367        let mut env = IndexMap::new();
368        if let Some((k, v)) = env_var {
369            env.insert(k.to_string(), v.to_string());
370        }
371        ConfigEntry::McpServer(McpServerEntry {
372            name: name.to_string(),
373            command: "npx".to_string(),
374            args: vec![],
375            env,
376        })
377    }
378
379    #[test]
380    fn write_mcp_creates_mcp_json() {
381        let tmp = TempDir::new().unwrap();
382        let adapter = CursorAdapter;
383        let entries = vec![make_mcp_entry("context7", None)];
384        let written = adapter
385            .write_config_entries(
386                write_permit(&entries)
387                    .bind_config_entries(entries.clone())
388                    .unwrap(),
389                tmp.path(),
390            )
391            .unwrap();
392        assert_eq!(written.len(), 1);
393        assert!(tmp.path().join("mcp.json").exists());
394
395        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
396        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
397        assert!(json["mcpServers"]["context7"].is_object());
398    }
399
400    #[test]
401    fn write_mcp_env_uses_cursor_interpolation() {
402        let tmp = TempDir::new().unwrap();
403        let adapter = CursorAdapter;
404        let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
405        adapter
406            .write_config_entries(
407                write_permit(&entries)
408                    .bind_config_entries(entries.clone())
409                    .unwrap(),
410                tmp.path(),
411            )
412            .unwrap();
413
414        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
415        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
416        // Cursor uses ${env:VAR_NAME} interpolation syntax
417        assert_eq!(
418            json["mcpServers"]["server"]["env"]["API_KEY"],
419            "${env:MY_SECRET}"
420        );
421    }
422
423    #[test]
424    fn remove_mcp_entries_preserves_others() {
425        let tmp = TempDir::new().unwrap();
426        let adapter = CursorAdapter;
427        let entries = vec![
428            make_mcp_entry("to-remove", None),
429            make_mcp_entry("to-keep", None),
430        ];
431        adapter
432            .write_config_entries(
433                write_permit(&entries)
434                    .bind_config_entries(entries.clone())
435                    .unwrap(),
436                tmp.path(),
437            )
438            .unwrap();
439
440        remove_cursor_mcp_entries(&["mcp:to-remove".to_string()], tmp.path()).unwrap();
441
442        let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
443        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
444        assert!(json["mcpServers"]["to-remove"].is_null());
445        assert!(json["mcpServers"]["to-keep"].is_object());
446    }
447
448    #[test]
449    fn divergent_structural_removal_does_not_rewrite_hooks() {
450        let tmp = TempDir::new().unwrap();
451        let path = tmp.path().join("hooks.json");
452        let original =
453            br#"{"version":1,"hooks":{"sessionStart":[{"command":"edited"}]},"keep":true}"#;
454        std::fs::write(&path, original).unwrap();
455        let before_modified = std::fs::metadata(&path).unwrap().modified().unwrap();
456        std::thread::sleep(std::time::Duration::from_millis(20));
457        let records = std::collections::BTreeMap::from([(
458            "hook:sessionStart:audit".to_string(),
459            crate::lock::ConfigEntryRecord {
460                emitted_json: Some(serde_json::json!([{"command":"original"}]).to_string()),
461            },
462        )]);
463
464        remove_owned_cursor_hooks(
465            &records,
466            tmp.path(),
467            &mut crate::diagnostic::DiagnosticCollector::new(),
468        )
469        .unwrap();
470
471        assert_eq!(std::fs::read(&path).unwrap(), original);
472        assert_eq!(
473            std::fs::metadata(&path).unwrap().modified().unwrap(),
474            before_modified
475        );
476    }
477
478    #[test]
479    fn removal_prunes_empty_hooks_object() {
480        let tmp = TempDir::new().unwrap();
481        let path = tmp.path().join("hooks.json");
482        let owned = serde_json::json!({"command":"owned"});
483        std::fs::write(
484            &path,
485            serde_json::json!({
486                "version": 1,
487                "hooks": {"sessionStart": [owned.clone()]}
488            })
489            .to_string(),
490        )
491        .unwrap();
492        let records = std::collections::BTreeMap::from([(
493            "hook:sessionStart:audit".to_string(),
494            crate::lock::ConfigEntryRecord {
495                emitted_json: Some(serde_json::json!([owned]).to_string()),
496            },
497        )]);
498
499        remove_owned_cursor_hooks(
500            &records,
501            tmp.path(),
502            &mut crate::diagnostic::DiagnosticCollector::new(),
503        )
504        .unwrap();
505
506        let after: serde_json::Value =
507            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
508        assert_eq!(after, serde_json::json!({"version": 1}));
509    }
510}