use std::path::{Path, PathBuf};
use crate::compiler::mcp::{HeaderValue, McpTransport};
use crate::diagnostic::DiagnosticCollector;
use crate::error::MarsError;
use crate::lock::ItemKind;
use crate::types::DestPath;
use super::{ConfigEntry, McpServerEntry, TargetAdapter};
#[derive(Debug)]
pub struct CursorAdapter;
impl TargetAdapter for CursorAdapter {
fn name(&self) -> &str {
".cursor"
}
fn skill_variant_key(&self) -> Option<&str> {
Some("cursor")
}
fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
match kind {
ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
_ => None,
}
}
fn emit_pre_write_diagnostics(
&self,
entries: &[ConfigEntry],
_target_dir: &Path,
diag: &mut crate::diagnostic::DiagnosticCollector,
) {
CursorAdapter::emit_hook_lossiness_diagnostics(entries, diag);
}
fn write_config_entries(
&self,
entries: &[ConfigEntry],
target_dir: &Path,
) -> Result<Vec<PathBuf>, MarsError> {
let mcp_servers: Vec<&McpServerEntry> = entries
.iter()
.filter_map(|e| {
if let ConfigEntry::McpServer(s) = e {
Some(s)
} else {
None
}
})
.collect();
if mcp_servers.is_empty() {
return Ok(Vec::new());
}
let path = write_cursor_mcp_json(target_dir, &mcp_servers)?;
Ok(vec![path])
}
fn remove_config_entries(
&self,
entry_keys: &[String],
target_dir: &Path,
) -> Result<(), MarsError> {
remove_cursor_mcp_entries(entry_keys, target_dir)
}
}
impl CursorAdapter {
pub fn emit_hook_lossiness_diagnostics(
entries: &[ConfigEntry],
diag: &mut DiagnosticCollector,
) {
for entry in entries {
if let ConfigEntry::Hook(hook) = entry {
diag.warn(
"hook-dropped",
format!(
"hook `{}` (event `{}`) dropped for target `.cursor` — \
Cursor has no native hook support",
hook.name, hook.event
),
);
}
}
}
}
fn write_cursor_mcp_json(
target_dir: &Path,
servers: &[&McpServerEntry],
) -> Result<PathBuf, MarsError> {
let path = target_dir.join("mcp.json");
let mut root: serde_json::Value = if path.is_file() {
let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let mcp_obj = root
.as_object_mut()
.ok_or_else(|| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{} is not a JSON object", path.display()),
})
})?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{}: mcpServers is not an object", path.display()),
})
})?;
for server in servers {
let mut entry = match server.transport {
McpTransport::Stdio => serde_json::json!({
"command": server.command,
"args": server.args,
}),
McpTransport::Http => {
let mut http_entry = serde_json::json!({
"type": "http",
"url": server.url,
});
if !server.headers.is_empty() {
let headers_obj: serde_json::Map<String, serde_json::Value> = server
.headers
.iter()
.map(|(k, v)| {
let value = match v {
HeaderValue::EnvRef(env_ref) => serde_json::Value::String(format!(
"${{env:{}}}",
env_ref.var_name()
)),
HeaderValue::Plain(plain) => {
serde_json::Value::String(plain.clone())
}
};
(k.clone(), value)
})
.collect();
http_entry["headers"] = serde_json::Value::Object(headers_obj);
}
http_entry
}
};
if !server.env.is_empty() {
let env_obj: serde_json::Map<String, serde_json::Value> = server
.env
.iter()
.map(|(k, v)| {
(
k.clone(),
serde_json::Value::String(format!("${{env:{v}}}")),
)
})
.collect();
entry["env"] = serde_json::Value::Object(env_obj);
}
mcp_map.insert(server.name.clone(), entry);
}
let content = serde_json::to_string_pretty(&root).map_err(|e| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("failed to serialize {}: {e}", path.display()),
})
})?;
crate::fs::atomic_write(&path, content.as_bytes())?;
Ok(path)
}
fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
let path = target_dir.join("mcp.json");
if !path.is_file() {
return Ok(());
}
let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
let mut root: serde_json::Value =
serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
if let Some(mcp_map) = root
.as_object_mut()
.and_then(|o| o.get_mut("mcpServers"))
.and_then(|v| v.as_object_mut())
{
for key in entry_keys {
if let Some(name) = key.strip_prefix("mcp:") {
mcp_map.remove(name);
}
}
}
let content = serde_json::to_string_pretty(&root).map_err(|e| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("failed to serialize {}: {e}", path.display()),
})
})?;
crate::fs::atomic_write(&path, content.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::target::{HookEntry, McpServerEntry};
use indexmap::IndexMap;
use tempfile::TempDir;
fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
let mut env = IndexMap::new();
if let Some((k, v)) = env_var {
env.insert(k.to_string(), v.to_string());
}
ConfigEntry::McpServer(McpServerEntry {
name: name.to_string(),
transport: McpTransport::Stdio,
command: Some("npx".to_string()),
args: vec![],
env,
url: None,
headers: IndexMap::new(),
})
}
fn make_hook_entry(name: &str) -> ConfigEntry {
ConfigEntry::Hook(HookEntry {
name: name.to_string(),
event: "tool.pre".to_string(),
native_event: "PreToolUse".to_string(),
script_path: "/hooks/run.sh".to_string(),
order: 0,
})
}
#[test]
fn write_mcp_creates_mcp_json() {
let tmp = TempDir::new().unwrap();
let adapter = CursorAdapter;
let entries = vec![make_mcp_entry("context7", None)];
let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
assert_eq!(written.len(), 1);
assert!(tmp.path().join("mcp.json").exists());
let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(json["mcpServers"]["context7"].is_object());
}
#[test]
fn write_mcp_env_uses_cursor_interpolation() {
let tmp = TempDir::new().unwrap();
let adapter = CursorAdapter;
let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
adapter.write_config_entries(&entries, tmp.path()).unwrap();
let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
json["mcpServers"]["server"]["env"]["API_KEY"],
"${env:MY_SECRET}"
);
}
#[test]
fn write_hooks_dropped_no_file_written() {
let tmp = TempDir::new().unwrap();
let adapter = CursorAdapter;
let entries = vec![make_hook_entry("audit")];
let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
assert!(written.is_empty());
assert!(!tmp.path().join("settings.json").exists());
}
#[test]
fn hook_lossiness_emits_diagnostic() {
let entries = vec![make_hook_entry("audit")];
let mut diag = crate::diagnostic::DiagnosticCollector::new();
CursorAdapter::emit_hook_lossiness_diagnostics(&entries, &mut diag);
let collected = diag.drain();
assert_eq!(collected.len(), 1);
assert!(collected[0].message.contains("dropped"));
}
#[test]
fn remove_mcp_entries_preserves_others() {
let tmp = TempDir::new().unwrap();
let adapter = CursorAdapter;
let entries = vec![
make_mcp_entry("to-remove", None),
make_mcp_entry("to-keep", None),
];
adapter.write_config_entries(&entries, tmp.path()).unwrap();
adapter
.remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
.unwrap();
let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(json["mcpServers"]["to-remove"].is_null());
assert!(json["mcpServers"]["to-keep"].is_object());
}
}