use crate::errors::{McpError, McpErrorKind};
pub mod claude;
pub mod codex;
#[path = "continue_.rs"]
pub mod continue_;
pub mod cursor;
pub mod shared_json;
pub mod windsurf;
#[derive(Debug, Clone)]
pub enum Scope {
Global,
Project { dir: std::path::PathBuf },
}
#[derive(Debug, Clone)]
pub struct InstallOpts {
pub profile: String,
pub allowed_keys: Vec<String>,
pub denied_keys: Vec<String>,
pub contract: Option<String>,
pub allow_reveal: bool,
pub name: Option<String>,
pub scope: Scope,
pub dry_run: bool,
pub uninstall: bool,
pub audit_source: Option<String>,
}
impl InstallOpts {
pub fn entry_name(&self) -> String {
self.name
.clone()
.unwrap_or_else(|| format!("tsafe-{}", self.profile))
}
pub fn server_args(&self) -> Vec<String> {
let mut out = vec!["--profile".to_string(), self.profile.clone()];
if !self.allowed_keys.is_empty() {
out.push("--allowed-keys".to_string());
out.push(self.allowed_keys.join(","));
}
if !self.denied_keys.is_empty() {
out.push("--denied-keys".to_string());
out.push(self.denied_keys.join(","));
}
if let Some(c) = &self.contract {
out.push("--contract".to_string());
out.push(c.clone());
}
if self.allow_reveal {
out.push("--allow-reveal".to_string());
}
if let Some(src) = &self.audit_source {
out.push("--audit-source".to_string());
out.push(src.clone());
}
out
}
}
pub fn dispatch(host: &str, opts: &InstallOpts) -> Result<(), McpError> {
if !opts.uninstall && opts.allowed_keys.is_empty() && opts.contract.is_none() {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
"no scope: --allowed-keys or --contract required (thin-stance: blank scope refused)",
));
}
let name = opts.entry_name();
if !name_is_toml_safe(&name) {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
format!("entry name '{name}' must match [a-zA-Z0-9_-]+ for TOML compatibility"),
));
}
match host.to_ascii_lowercase().as_str() {
"claude" | "claude-desktop" => claude::write(opts),
"cursor" => cursor::write(opts),
"continue" => continue_::write(opts),
"windsurf" => windsurf::write(opts),
"codex" => codex::write(opts),
_ => Err(McpError::new(
McpErrorKind::InstallTargetUnknown,
format!("unknown host '{host}'. Supported: claude, cursor, continue, windsurf, codex"),
)),
}
}
fn name_is_toml_safe(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn opts_minimal() -> InstallOpts {
InstallOpts {
profile: "demo".to_string(),
allowed_keys: vec!["demo/*".to_string()],
denied_keys: vec![],
contract: None,
allow_reveal: false,
name: None,
scope: Scope::Global,
dry_run: true,
uninstall: false,
audit_source: None,
}
}
#[test]
fn unknown_host_returns_install_target_unknown() {
let err = dispatch("notreal", &opts_minimal()).unwrap_err();
assert_eq!(err.kind, McpErrorKind::InstallTargetUnknown);
assert!(err
.message
.contains("claude, cursor, continue, windsurf, codex"));
}
#[test]
fn blank_scope_is_refused() {
let mut opts = opts_minimal();
opts.allowed_keys.clear();
let err = dispatch("claude", &opts).unwrap_err();
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
assert!(err.message.contains("scope"));
}
#[test]
fn bad_name_rejected_before_dispatch() {
let mut opts = opts_minimal();
opts.name = Some("not.toml.safe".to_string());
let err = dispatch("claude", &opts).unwrap_err();
assert!(err.message.contains("must match"));
}
#[test]
fn default_entry_name_uses_tsafe_profile() {
let opts = opts_minimal();
assert_eq!(opts.entry_name(), "tsafe-demo");
}
#[test]
fn server_args_serializes_flags_in_order() {
let mut opts = opts_minimal();
opts.denied_keys = vec!["demo/secret".to_string()];
opts.contract = Some("deploy".to_string());
opts.allow_reveal = true;
opts.audit_source = Some("mcp:claude:proof".to_string());
let args = opts.server_args();
assert_eq!(args[0], "--profile");
assert_eq!(args[1], "demo");
assert!(args.iter().any(|s| s == "--allowed-keys"));
assert!(args.iter().any(|s| s == "--denied-keys"));
assert!(args.iter().any(|s| s == "--contract"));
assert!(args.iter().any(|s| s == "--allow-reveal"));
assert!(args.iter().any(|s| s == "--audit-source"));
}
}