diff --git a/CONTEXT.md b/CONTEXT.md index a2e5bfa..1bc3fad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,14 +106,15 @@ When using MCP server or `--yes` flag, provide `acknowledged=true` in parameters ## MCP Server -Start the MCP server with specified service groups: +Configure and start the MCP server: ```bash -cdcx mcp --services market,trade,account -cdcx mcp --services market,trade --allow-dangerous +cdcx mcp config --enable trade,account +cdcx mcp config --allow-dangerous +cdcx mcp ``` -Default services: `market` (public endpoints only) +Default services: `market` (public endpoints only). Config stored in `~/.config/cdcx/mcp.toml`. See `agents/AGENTS.md` for full MCP integration guide. diff --git a/README.md b/README.md index 1c5f671..76a21d7 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,7 @@ cargo install --git https://github.com/crypto-com/cdcx-cli.git --bin cdcx Every response is structured JSON. 86 MCP tools with typed parameters, enum validation, safety enforcement, and schema discovery — all generated from the OpenAPI spec at runtime. Your LLM can trade, analyze markets, and manage positions without custom tooling. ```bash -cdcx mcp --services market,account,trade -``` - -```json -{ - "mcpServers": { - "cdcx": { - "command": "npx", - "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market,account,trade"] - } - } -} +cdcx mcp config --enable trade,account ``` Compatible with Claude Code, Cursor, Claude Desktop, Codex, Github Copilot, Gemini CLI, and other MCP clients. Includes 13 agent skill files in `skills/` for guided workflows. @@ -108,11 +97,16 @@ cdcx account summary Expose the Exchange API as MCP tools for AI agents: ```bash -cdcx mcp --services market,account,trade -cdcx mcp --services all --allow-dangerous +cdcx mcp # Start server (reads config) +cdcx mcp config # Show current config +cdcx mcp config --enable trade,account # Enable service groups +cdcx mcp config --allow-dangerous # Allow withdrawals, cancel-all +cdcx mcp config --reset # Reset to defaults (market only) ``` -Service groups (MCP): `market`, `account`, `trade`, `advanced`, `margin`, `staking`, `funding`, `fiat`, `otc`, `stream`, `all` +Service groups: `market`, `account`, `trade`, `advanced`, `margin`, `staking`, `funding`, `fiat`, `otc`, `stream` + +Configuration is stored in `~/.config/cdcx/mcp.toml` and persists across updates. > **Note:** `account` also exposes historical endpoints (orders, trades, transactions). `funding` covers wallet deposit/withdrawal endpoints. Paper trading is a CLI-only feature and has no MCP tools. @@ -125,26 +119,6 @@ Service groups (MCP): `market`, `account`, `trade`, `advanced`, `margin`, `staki | **mutate** | Requires `acknowledged: true` | `trade order`, `trade cancel` | | **dangerous** | Requires `--allow-dangerous` | `trade cancel-all`, `wallet withdraw` | -### Agent Skills - -13 skill files in `skills/` covering: - -| Skill | Purpose | -|-------|---------| -| `cdcx-market-intel` | Market analysis and price discovery | -| `cdcx-portfolio-intel` | Portfolio analysis and risk assessment | -| `cdcx-execution` | Order placement with safety checks | -| `cdcx-advanced` | OCO, OTO, OTOCO contingency orders | -| `cdcx-paper-strategy` | Paper trading strategy testing | -| `cdcx-wallet-ops` | Deposits, withdrawals, network management | -| `cdcx-auth-setup` | Credential configuration | -| `cdcx-autonomy-levels` | Safety tier configuration | -| `cdcx-check-balance` | Balance and credential verification | -| `cdcx-place-limit-order` | Limit order workflow with preflight checks | -| `cdcx-isolated-margin` | Isolated margin trading (equity/RWA perpetuals) | -| `recipe-morning-brief` | Daily market briefing workflow | -| `recipe-emergency-flatten` | Emergency position flattening | - ### Plugin Installation Install cdcx as a one-click plugin from your AI coding tool's marketplace. @@ -168,18 +142,32 @@ codex plugin marketplace add crypto-com/cdcx-cli { "cdcx": { "command": "npx", - "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp"] } } ``` -To expand services beyond market data, update the `--services` flag: +Then configure services with `cdcx mcp config --enable trade,account`. -``` -market,trade,account # Trading agent -market,trade,account,advanced # With OCO/OTOCO -all --allow-dangerous # Full access (withdrawals enabled) -``` +### Agent Skills + +13 skill files in `skills/` covering: + +| Skill | Purpose | +|-------|---------| +| `cdcx-market-intel` | Market analysis and price discovery | +| `cdcx-portfolio-intel` | Portfolio analysis and risk assessment | +| `cdcx-execution` | Order placement with safety checks | +| `cdcx-advanced` | OCO, OTO, OTOCO contingency orders | +| `cdcx-paper-strategy` | Paper trading strategy testing | +| `cdcx-wallet-ops` | Deposits, withdrawals, network management | +| `cdcx-auth-setup` | Credential configuration | +| `cdcx-autonomy-levels` | Safety tier configuration | +| `cdcx-check-balance` | Balance and credential verification | +| `cdcx-place-limit-order` | Limit order workflow with preflight checks | +| `cdcx-isolated-margin` | Isolated margin trading (equity/RWA perpetuals) | +| `recipe-morning-brief` | Daily market briefing workflow | +| `recipe-emergency-flatten` | Emergency position flattening | --- @@ -307,6 +295,7 @@ cdcx tui --setup # Setup wizard | `p` | Toggle LIVE / PAPER mode | | `!` | Set price alert | | `y` | Copy to clipboard (CSV) | +| `,` | Settings (theme, MCP services) | | `?` | Help overlay | | `q` | Quit | @@ -323,6 +312,15 @@ cdcx setup # Interactive credential setup cdcx --profile uat account summary # Use named profile ``` +### MCP Config + +`~/.config/cdcx/mcp.toml` (managed via `cdcx mcp config` or TUI settings): + +```toml +services = ["market", "trade", "account"] +allow_dangerous = false +``` + ### TUI Config `~/.config/cdcx/tui.toml`: diff --git a/SECURITY.md b/SECURITY.md index 5b8ac3c..0d4bf56 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,13 +45,13 @@ There is **no `--api-key` or `--api-secret` CLI flag by design.** Command-line f | `read` | `market ticker`, `market book` | None required | | `sensitive_read` | `account summary`, `trade open-orders` | None required (authenticated) | | `mutate` | `trade order`, `trade cancel` | MCP requires `acknowledged: true` parameter | -| `dangerous` | `trade cancel-all`, `wallet withdraw` | Requires `cdcx mcp --allow-dangerous` flag at server startup | +| `dangerous` | `trade cancel-all`, `wallet withdraw` | Requires `cdcx mcp config --allow-dangerous` | When running the MCP server for an AI agent: -- Start with `--services market,account` to expose read-only capabilities first. -- Add `trade` only when the agent genuinely needs to place orders. -- Never pass `--allow-dangerous` unless you explicitly want the agent to be able to withdraw funds or bulk-cancel orders. +- Start with default config (`market` only) to expose read-only capabilities first. +- Enable `trade` only when the agent genuinely needs to place orders (`cdcx mcp config --enable trade`). +- Never enable `--allow-dangerous` unless you explicitly want the agent to be able to withdraw funds or bulk-cancel orders. ## Paper trading diff --git a/agents/AGENTS.md b/agents/AGENTS.md index f86c8d2..b8724ff 100644 --- a/agents/AGENTS.md +++ b/agents/AGENTS.md @@ -61,10 +61,11 @@ Expected response on auth failure: ### For Agents Using MCP -When running the MCP server, provide credentials in the initial setup: +Configure services and start the MCP server: ```bash -cdcx mcp --services market,trade,account +cdcx mcp config --enable trade,account +cdcx mcp ``` The server will attempt to resolve credentials from the environment or config file. If credentials are not available for private endpoints, the MCP server will reject requests with an auth error. @@ -209,17 +210,23 @@ For `mutate` and `dangerous` operations: } ``` -3. **Dangerous operations on MCP:** Require `--allow-dangerous` flag on server startup +3. **Dangerous operations on MCP:** Require `allow_dangerous` in config ```bash - cdcx mcp --services market,trade,wallet --allow-dangerous + cdcx mcp config --enable trade,funding --allow-dangerous ``` ## MCP Server Setup -### Starting the Server +### Configuration + +Services are managed via `cdcx mcp config` and persisted to `~/.config/cdcx/mcp.toml`: ```bash -cdcx mcp --services ,... [--allow-dangerous] +cdcx mcp config # Show current config +cdcx mcp config --enable trade,account # Enable service groups +cdcx mcp config --disable staking # Disable a service group +cdcx mcp config --allow-dangerous # Enable dangerous operations +cdcx mcp config --reset # Reset to defaults (market only) ``` ### Service Groups @@ -240,17 +247,17 @@ cdcx mcp --services ,... [--allow-dangerous] **Read-only (market data only):** ```bash -cdcx mcp --services market +cdcx mcp config --reset ``` **Trading agent (no withdrawals):** ```bash -cdcx mcp --services market,trade,account,history +cdcx mcp config --enable trade,account ``` **Full access (dangerous operations enabled):** ```bash -cdcx mcp --services market,trade,account,advanced,margin,staking,wallet,fiat --allow-dangerous +cdcx mcp config --enable all --allow-dangerous ``` ### Tool Registration diff --git a/crates/cdcx-cli/src/cli_builder.rs b/crates/cdcx-cli/src/cli_builder.rs index be0aae0..63956f8 100644 --- a/crates/cdcx-cli/src/cli_builder.rs +++ b/crates/cdcx-cli/src/cli_builder.rs @@ -148,18 +148,54 @@ pub fn build_static_cli() -> clap::Command { app.subcommand(clap::Command::new("setup").about("Configure API credentials and profiles")); app = app.subcommand( clap::Command::new("mcp") - .about("Start MCP server (stdio transport)") + .about("MCP server and configuration") + .args_conflicts_with_subcommands(true) .arg( clap::Arg::new("services") .long("services") - .default_value("market") - .help("Comma-separated service groups to expose"), + .help("Comma-separated service groups to expose (overrides mcp.toml)"), ) .arg( clap::Arg::new("allow-dangerous") .long("allow-dangerous") .action(clap::ArgAction::SetTrue) - .help("Allow dangerous operations"), + .help("Allow dangerous operations (overrides mcp.toml)"), + ) + .subcommand( + clap::Command::new("config") + .about("Manage MCP service configuration (~/.config/cdcx/mcp.toml)") + .arg( + clap::Arg::new("enable") + .long("enable") + .value_name("SERVICE") + .help("Enable a service group"), + ) + .arg( + clap::Arg::new("disable") + .long("disable") + .value_name("SERVICE") + .help("Disable a service group"), + ) + .arg( + clap::Arg::new("allow-dangerous") + .long("allow-dangerous") + .action(clap::ArgAction::SetTrue) + .conflicts_with("no-dangerous") + .help("Enable dangerous operations in config"), + ) + .arg( + clap::Arg::new("no-dangerous") + .long("no-dangerous") + .action(clap::ArgAction::SetTrue) + .conflicts_with("allow-dangerous") + .help("Disable dangerous operations in config"), + ) + .arg( + clap::Arg::new("reset") + .long("reset") + .action(clap::ArgAction::SetTrue) + .help("Reset configuration to defaults"), + ), ), ); diff --git a/crates/cdcx-cli/src/dispatch.rs b/crates/cdcx-cli/src/dispatch.rs index 40b6a5b..64cc036 100644 --- a/crates/cdcx-cli/src/dispatch.rs +++ b/crates/cdcx-cli/src/dispatch.rs @@ -653,12 +653,23 @@ pub async fn run_update(check_only: bool) -> Result<(), CdcxError> { } pub async fn run_mcp( - services: String, - allow_dangerous: bool, + services_override: Option, + allow_dangerous_flag: bool, ) -> Result<(), Box> { use cdcx_core::auth::Credentials; + use cdcx_core::config::McpConfig; use rmcp::ServiceExt; + // Load user's MCP config file (if it exists) + let mcp_config = McpConfig::load_default()?.unwrap_or_default(); + + // Resolve: CLI flag > config file > default + let services = match services_override { + Some(s) => s, + None => mcp_config.services_string(), + }; + let allow_dangerous = allow_dangerous_flag || mcp_config.allow_dangerous; + let service_groups: Vec = services.split(',').map(|s| s.trim().to_string()).collect(); // Resolve credentials if needed (for private endpoints) diff --git a/crates/cdcx-cli/src/main.rs b/crates/cdcx-cli/src/main.rs index 9efd553..dc52bc0 100644 --- a/crates/cdcx-cli/src/main.rs +++ b/crates/cdcx-cli/src/main.rs @@ -211,11 +211,31 @@ async fn main() { } } Some(("mcp", sub)) => { - let services = sub.get_one::("services").unwrap().clone(); - let allow_dangerous = sub.get_flag("allow-dangerous"); - if let Err(e) = dispatch::run_mcp(services, allow_dangerous).await { - eprintln!("MCP server error: {}", e); - std::process::exit(1); + if let Some(config_sub) = sub.subcommand_matches("config") { + let result = if config_sub.get_flag("reset") { + mcp::config::reset_config() + } else if let Some(service) = config_sub.get_one::("enable") { + mcp::config::enable_service(service) + } else if let Some(service) = config_sub.get_one::("disable") { + mcp::config::disable_service(service) + } else if config_sub.get_flag("allow-dangerous") { + mcp::config::set_allow_dangerous(true) + } else if config_sub.get_flag("no-dangerous") { + mcp::config::set_allow_dangerous(false) + } else { + mcp::config::show_config() + }; + if let Err(e) = result { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } else { + let services = sub.get_one::("services").cloned(); + let allow_dangerous = sub.get_flag("allow-dangerous"); + if let Err(e) = dispatch::run_mcp(services, allow_dangerous).await { + eprintln!("MCP server error: {}", e); + std::process::exit(1); + } } } Some((group, sub)) => { diff --git a/crates/cdcx-cli/src/mcp/config.rs b/crates/cdcx-cli/src/mcp/config.rs new file mode 100644 index 0000000..589a1da --- /dev/null +++ b/crates/cdcx-cli/src/mcp/config.rs @@ -0,0 +1,90 @@ +use cdcx_core::config::McpConfig; +use cdcx_core::error::CdcxError; + +const VALID_SERVICES: &[&str] = &[ + "market", "account", "trade", "advanced", "margin", "staking", "funding", "fiat", "otc", + "stream", +]; + +fn validate_service(name: &str) -> Result<(), CdcxError> { + if name != "all" && !VALID_SERVICES.contains(&name) { + return Err(CdcxError::Config(format!( + "Unknown service group: '{}'. Valid groups: {}, all", + name, + VALID_SERVICES.join(", ") + ))); + } + Ok(()) +} + +pub fn show_config() -> Result<(), CdcxError> { + let config = McpConfig::load_default()?.unwrap_or_default(); + let path = McpConfig::default_path(); + let path_display = path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()); + let file_exists = path.map(|p| p.exists()).unwrap_or(false); + + eprintln!("MCP configuration:"); + eprintln!( + " file: {}{}", + path_display, + if file_exists { "" } else { " (using defaults)" } + ); + eprintln!(" services: {}", config.services.join(", ")); + eprintln!(" allow_dangerous: {}", config.allow_dangerous); + Ok(()) +} + +pub fn enable_service(service: &str) -> Result<(), CdcxError> { + validate_service(service)?; + let mut config = McpConfig::load_default()?.unwrap_or_default(); + if service == "all" { + config.services = VALID_SERVICES.iter().map(|s| s.to_string()).collect(); + } else if !config.services.iter().any(|s| s == service) { + config.services.push(service.to_string()); + } + config.save()?; + eprintln!("Enabled service: {}", service); + eprintln!("Active services: {}", config.services.join(", ")); + Ok(()) +} + +pub fn disable_service(service: &str) -> Result<(), CdcxError> { + validate_service(service)?; + let mut config = McpConfig::load_default()?.unwrap_or_default(); + if service == "all" { + config.services = vec!["market".to_string()]; + } else { + config.services.retain(|s| s != service); + if config.services.is_empty() { + config.services.push("market".to_string()); + eprintln!("Cannot disable all services; keeping 'market' as minimum."); + } + } + config.save()?; + eprintln!("Disabled service: {}", service); + eprintln!("Active services: {}", config.services.join(", ")); + Ok(()) +} + +pub fn set_allow_dangerous(enabled: bool) -> Result<(), CdcxError> { + let mut config = McpConfig::load_default()?.unwrap_or_default(); + config.allow_dangerous = enabled; + config.save()?; + if enabled { + eprintln!("Dangerous operations: enabled"); + } else { + eprintln!("Dangerous operations: disabled"); + } + Ok(()) +} + +pub fn reset_config() -> Result<(), CdcxError> { + McpConfig::delete()?; + eprintln!("MCP configuration reset to defaults."); + eprintln!(" services: market"); + eprintln!(" allow_dangerous: false"); + Ok(()) +} diff --git a/crates/cdcx-cli/src/mcp/mod.rs b/crates/cdcx-cli/src/mcp/mod.rs index 0ec595c..306e67b 100644 --- a/crates/cdcx-cli/src/mcp/mod.rs +++ b/crates/cdcx-cli/src/mcp/mod.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod gating; pub mod server; pub mod tools; diff --git a/crates/cdcx-cli/tests/cli_smoke.rs b/crates/cdcx-cli/tests/cli_smoke.rs index 35d0ade..12caf0c 100644 --- a/crates/cdcx-cli/tests/cli_smoke.rs +++ b/crates/cdcx-cli/tests/cli_smoke.rs @@ -1,5 +1,6 @@ use assert_cmd::Command; use predicates::str; +use std::fs; /// Returns true if a cached OpenAPI spec exists. /// Integration tests that exercise dynamic API groups require this. @@ -494,7 +495,6 @@ fn test_update_help_hides_global_flags() { #[test] #[cfg(unix)] fn test_update_disable_writes_config() { - use std::fs; let dir = tempfile::tempdir().unwrap(); Command::cargo_bin("cdcx") @@ -512,7 +512,6 @@ fn test_update_disable_writes_config() { #[test] #[cfg(unix)] fn test_update_enable_after_disable() { - use std::fs; let dir = tempfile::tempdir().unwrap(); let config_dir = dir.path().join(".config/cdcx"); fs::create_dir_all(&config_dir).unwrap(); @@ -533,3 +532,123 @@ fn test_update_enable_after_disable() { let content = fs::read_to_string(config_dir.join("config.toml")).unwrap(); assert!(content.contains("disable_update_check = false")); } + +// --- MCP config tests --- + +#[test] +fn test_mcp_config_help() { + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--help"]) + .assert() + .success() + .stdout(str::contains("--enable")) + .stdout(str::contains("--disable")) + .stdout(str::contains("--reset")); +} + +#[test] +#[cfg(unix)] +fn test_mcp_config_show_default() { + let dir = tempfile::tempdir().unwrap(); + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("market")) + .stderr(str::contains("allow_dangerous: false")); +} + +#[test] +#[cfg(unix)] +fn test_mcp_config_enable_disable() { + let dir = tempfile::tempdir().unwrap(); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--enable", "trade"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("Enabled service: trade")); + + let content = fs::read_to_string(dir.path().join(".config/cdcx/mcp.toml")).unwrap(); + assert!(content.contains("trade")); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--disable", "trade"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("Disabled service: trade")); + + let content = fs::read_to_string(dir.path().join(".config/cdcx/mcp.toml")).unwrap(); + assert!(!content.contains("trade")); +} + +#[test] +#[cfg(unix)] +fn test_mcp_config_allow_dangerous() { + let dir = tempfile::tempdir().unwrap(); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--allow-dangerous"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("enabled")); + + let content = fs::read_to_string(dir.path().join(".config/cdcx/mcp.toml")).unwrap(); + assert!(content.contains("allow_dangerous = true")); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--no-dangerous"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("disabled")); + + let content = fs::read_to_string(dir.path().join(".config/cdcx/mcp.toml")).unwrap(); + assert!(content.contains("allow_dangerous = false")); +} + +#[test] +#[cfg(unix)] +fn test_mcp_config_reset() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config/cdcx"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("mcp.toml"), + "services = [\"market\", \"trade\"]\nallow_dangerous = true\n", + ) + .unwrap(); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--reset"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("reset to defaults")); + + assert!(!config_dir.join("mcp.toml").exists()); +} + +#[test] +#[cfg(unix)] +fn test_mcp_config_invalid_service() { + let dir = tempfile::tempdir().unwrap(); + Command::cargo_bin("cdcx") + .unwrap() + .args(["mcp", "config", "--enable", "bogus"]) + .env("HOME", dir.path()) + .assert() + .failure() + .stderr(str::contains("Unknown service group")); +} diff --git a/crates/cdcx-core/src/config.rs b/crates/cdcx-core/src/config.rs index 2bc7359..6286f6d 100644 --- a/crates/cdcx-core/src/config.rs +++ b/crates/cdcx-core/src/config.rs @@ -185,6 +185,84 @@ pub fn set_dir_owner_only(path: &std::path::Path) -> Result<(), std::io::Error> Ok(()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpConfig { + #[serde(default = "McpConfig::default_services")] + pub services: Vec, + #[serde(default)] + pub allow_dangerous: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + services: Self::default_services(), + allow_dangerous: false, + } + } +} + +impl McpConfig { + fn default_services() -> Vec { + vec!["market".to_string()] + } + + pub fn default_path() -> Option { + dirs::home_dir().map(|h| h.join(".config").join("cdcx").join("mcp.toml")) + } + + pub fn load_default() -> Result, CdcxError> { + let path = match Self::default_path() { + Some(p) => p, + None => return Ok(None), + }; + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(CdcxError::Config(format!( + "Failed to read mcp config: {}", + e + ))) + } + }; + let config: Self = toml::from_str(&content) + .map_err(|e| CdcxError::Config(format!("Failed to parse mcp.toml: {}", e)))?; + Ok(Some(config)) + } + + pub fn save(&self) -> Result<(), CdcxError> { + let path = Self::default_path() + .ok_or_else(|| CdcxError::Config("Cannot determine home directory".into()))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| CdcxError::Config(format!("Failed to create config dir: {}", e)))?; + } + let content = toml::to_string_pretty(self) + .map_err(|e| CdcxError::Config(format!("Failed to serialize mcp config: {}", e)))?; + std::fs::write(&path, content) + .map_err(|e| CdcxError::Config(format!("Failed to write mcp.toml: {}", e)))?; + Ok(()) + } + + pub fn delete() -> Result<(), CdcxError> { + let path = Self::default_path() + .ok_or_else(|| CdcxError::Config("Cannot determine home directory".into()))?; + match std::fs::remove_file(&path) { + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(CdcxError::Config(format!( + "Failed to remove mcp.toml: {}", + e + ))), + } + } + + pub fn services_string(&self) -> String { + self.services.join(",") + } +} + impl Config { /// Return the default config path (~/.config/cdcx/config.toml). pub fn default_path() -> Option { @@ -312,6 +390,42 @@ environment = "production" assert!(config.default.is_none()); } + mod mcp_config_tests { + use super::*; + + #[test] + fn test_default_mcp_config() { + let config = McpConfig::default(); + assert_eq!(config.services, vec!["market"]); + assert!(!config.allow_dangerous); + } + + #[test] + fn test_parse_mcp_config() { + let toml_str = + "services = [\"market\", \"trade\", \"account\"]\nallow_dangerous = true\n"; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.services, vec!["market", "trade", "account"]); + assert!(config.allow_dangerous); + } + + #[test] + fn test_parse_empty_mcp_config() { + let config: McpConfig = toml::from_str("").unwrap(); + assert_eq!(config.services, vec!["market"]); + assert!(!config.allow_dangerous); + } + + #[test] + fn test_services_string() { + let config = McpConfig { + services: vec!["market".into(), "trade".into()], + allow_dangerous: false, + }; + assert_eq!(config.services_string(), "market,trade"); + } + } + #[cfg(unix)] mod permission_tests { use super::super::check_config_permissions; diff --git a/crates/cdcx-tui/src/app.rs b/crates/cdcx-tui/src/app.rs index 5a6c2f7..788362a 100644 --- a/crates/cdcx-tui/src/app.rs +++ b/crates/cdcx-tui/src/app.rs @@ -85,6 +85,12 @@ impl App { } pub fn on_key(&mut self, key: KeyEvent) { + // Ctrl+C: quit (always works, regardless of overlays) + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { + self.should_quit = true; + return; + } + // Settings panel — intercept all keys when open if let Some(ref mut panel) = self.settings { match panel.on_key(key) { @@ -101,6 +107,7 @@ impl App { theme, tick_rate_ms, ticker_speed_divisor, + mcp_error, } => { let theme_name = theme.name.clone(); self.state.theme = theme; @@ -117,9 +124,17 @@ impl App { tick_rate_ms, speed_key, ) { - Ok(_) => self - .state - .toast("Settings saved", crate::state::ToastStyle::Success), + Ok(_) => { + if let Some(e) = mcp_error { + self.state.toast( + format!("MCP config save failed: {}", e), + crate::state::ToastStyle::Error, + ); + } else { + self.state + .toast("Settings saved", crate::state::ToastStyle::Success); + } + } Err(e) => self.state.toast( format!("Save failed: {}", e), crate::state::ToastStyle::Error, @@ -152,12 +167,6 @@ impl App { return; } - // Ctrl+C: quit (always works, even during workflows) - if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { - self.should_quit = true; - return; - } - // If a workflow is active, delegate all input to it if self.mode == Mode::Workflow { if let Some(ref mut wf) = self.workflow { diff --git a/crates/cdcx-tui/src/setup.rs b/crates/cdcx-tui/src/setup.rs index 11b67e5..7d04fd9 100644 --- a/crates/cdcx-tui/src/setup.rs +++ b/crates/cdcx-tui/src/setup.rs @@ -289,19 +289,19 @@ impl SetupState { " BTC_USDT ", Style::default().fg(c.accent).add_modifier(Modifier::BOLD), ), - Span::styled("67,234.50 ", Style::default().fg(c.fg)), + Span::styled("12,345.67 ", Style::default().fg(c.fg)), Span::styled("+2.34% ", Style::default().fg(c.positive)), Span::styled("Vol: 1.2B ", Style::default().fg(c.volume)), ]), Line::from(vec![ Span::styled(" ETH_USDT ", Style::default().fg(c.fg)), - Span::styled(" 3,456.78 ", Style::default().fg(c.fg)), + Span::styled(" 1,234.56 ", Style::default().fg(c.fg)), Span::styled("-0.87% ", Style::default().fg(c.negative)), Span::styled("Vol: 892M ", Style::default().fg(c.volume)), ]), Line::from(vec![ Span::styled(" SOL_USDT ", Style::default().fg(c.fg)), - Span::styled(" 178.92 ", Style::default().fg(c.fg)), + Span::styled(" 123.45 ", Style::default().fg(c.fg)), Span::styled("+5.12% ", Style::default().fg(c.positive)), Span::styled("Vol: 445M ", Style::default().fg(c.volume)), ]), diff --git a/crates/cdcx-tui/src/widgets/settings.rs b/crates/cdcx-tui/src/widgets/settings.rs index af48959..2a52b0d 100644 --- a/crates/cdcx-tui/src/widgets/settings.rs +++ b/crates/cdcx-tui/src/widgets/settings.rs @@ -20,28 +20,21 @@ const TICKER_SPEEDS: &[(u64, &str, &str)] = &[ (1, "Fast", "fast"), ]; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SettingsRow { - Theme, - TickerSpeed, - TickRate, -} +const MCP_SERVICES: &[(&str, &str)] = &[ + ("market", "Tickers, orderbook, candles"), + ("account", "Balances, positions, history"), + ("trade", "Place, amend, cancel orders"), + ("advanced", "OCO, OTO, OTOCO orders"), + ("margin", "Margin transfers, leverage"), + ("staking", "Stake/unstake operations"), + ("funding", "Withdrawals (dangerous)"), + ("fiat", "Fiat operations (dangerous)"), +]; -impl SettingsRow { - const ALL: &[SettingsRow] = &[ - SettingsRow::Theme, - SettingsRow::TickerSpeed, - SettingsRow::TickRate, - ]; - - fn label(&self) -> &'static str { - match self { - SettingsRow::Theme => "Theme", - SettingsRow::TickerSpeed => "Ticker Tape", - SettingsRow::TickRate => "Tick Rate", - } - } -} +/// Total number of rows: 3 TUI settings + MCP_SERVICES checkboxes + 1 allow_dangerous toggle +const TUI_ROW_COUNT: usize = 3; +const MCP_ROW_COUNT: usize = 9; // 8 services + 1 allow_dangerous +const TOTAL_ROWS: usize = TUI_ROW_COUNT + MCP_ROW_COUNT; pub enum SettingsAction { /// Keep the panel open, no external effect. @@ -55,6 +48,7 @@ pub enum SettingsAction { theme: Theme, tick_rate_ms: u64, ticker_speed_divisor: u64, + mcp_error: Option, }, /// User closed without saving — caller should revert theme and speed. Close, @@ -70,6 +64,8 @@ pub struct SettingsPanel { original_ticker_speed_divisor: u64, original_tick_rate_ms: u64, saved: bool, + mcp_enabled: [bool; 8], // one per MCP_SERVICES entry + mcp_dangerous: bool, } impl SettingsPanel { @@ -96,7 +92,18 @@ impl SettingsPanel { let ticker_speed_idx = TICKER_SPEEDS .iter() .position(|(div, _, _)| *div == current_ticker_speed_divisor) - .unwrap_or(1); // default to medium + .unwrap_or(1); + + // Load MCP config + let mcp_config = cdcx_core::config::McpConfig::load_default() + .ok() + .flatten() + .unwrap_or_default(); + let mut mcp_enabled = [false; 8]; + for (i, (name, _)) in MCP_SERVICES.iter().enumerate() { + mcp_enabled[i] = mcp_config.services.iter().any(|s| s == name); + } + let mcp_dangerous = mcp_config.allow_dangerous; Self { selected: 0, @@ -108,6 +115,8 @@ impl SettingsPanel { original_ticker_speed_divisor: current_ticker_speed_divisor, original_tick_rate_ms: current_tick_rate_ms, saved: false, + mcp_enabled, + mcp_dangerous, } } @@ -131,13 +140,21 @@ impl SettingsPanel { TICKER_SPEEDS[self.ticker_speed_idx].1 } + fn is_mcp_row(&self) -> bool { + self.selected >= TUI_ROW_COUNT + } + + fn mcp_row_index(&self) -> usize { + self.selected - TUI_ROW_COUNT + } + pub fn on_key(&mut self, key: KeyEvent) -> SettingsAction { match key.code { KeyCode::Esc | KeyCode::Char(',') => { if self.saved { return SettingsAction::Close; } - // Revert theme and ticker speed if changed but not saved + // Revert live-previewed TUI changes let theme_changed = self.selected_theme_name() != self.original_theme_name; let speed_changed = self.selected_ticker_speed_divisor() != self.original_ticker_speed_divisor; @@ -147,9 +164,11 @@ impl SettingsPanel { theme: original, tick_rate_ms: self.original_tick_rate_ms, ticker_speed_divisor: self.original_ticker_speed_divisor, + mcp_error: None, }; } } + // MCP changes are discarded (never written to disk) SettingsAction::Close } KeyCode::Up => { @@ -159,28 +178,55 @@ impl SettingsPanel { SettingsAction::None } KeyCode::Down => { - if self.selected < SettingsRow::ALL.len() - 1 { + if self.selected < TOTAL_ROWS - 1 { self.selected += 1; } SettingsAction::None } + KeyCode::Char(' ') => { + if self.is_mcp_row() { + self.toggle_mcp(); + } + SettingsAction::None + } KeyCode::Left => self.cycle_value(-1), KeyCode::Right | KeyCode::Tab => self.cycle_value(1), KeyCode::Enter => { self.saved = true; + let mcp_error = self.save_mcp_config().err(); SettingsAction::Save { theme: self.selected_theme().clone(), tick_rate_ms: self.selected_tick_rate_ms(), ticker_speed_divisor: self.selected_ticker_speed_divisor(), + mcp_error, } } _ => SettingsAction::None, } } + fn toggle_mcp(&mut self) { + let idx = self.mcp_row_index(); + if idx < MCP_SERVICES.len() { + if idx == 0 { + // "market" cannot be disabled + self.mcp_enabled[0] = true; + } else { + self.mcp_enabled[idx] = !self.mcp_enabled[idx]; + } + } else { + self.mcp_dangerous = !self.mcp_dangerous; + } + } + fn cycle_value(&mut self, direction: i32) -> SettingsAction { - match SettingsRow::ALL[self.selected] { - SettingsRow::Theme => { + if self.is_mcp_row() { + self.toggle_mcp(); + return SettingsAction::None; + } + match self.selected { + 0 => { + // Theme let len = self.themes.len(); if direction > 0 { self.theme_idx = (self.theme_idx + 1) % len; @@ -189,7 +235,8 @@ impl SettingsPanel { } SettingsAction::ThemeChanged(self.selected_theme().clone()) } - SettingsRow::TickerSpeed => { + 1 => { + // TickerSpeed let len = TICKER_SPEEDS.len(); if direction > 0 { self.ticker_speed_idx = (self.ticker_speed_idx + 1) % len; @@ -198,7 +245,8 @@ impl SettingsPanel { } SettingsAction::TickerSpeedChanged(self.selected_ticker_speed_divisor()) } - SettingsRow::TickRate => { + 2 => { + // TickRate let len = TICK_RATES.len(); if direction > 0 { self.tick_rate_idx = (self.tick_rate_idx + 1) % len; @@ -207,12 +255,27 @@ impl SettingsPanel { } SettingsAction::None } + _ => SettingsAction::None, } } + fn save_mcp_config(&self) -> Result<(), String> { + let services: Vec = MCP_SERVICES + .iter() + .enumerate() + .filter(|(i, _)| self.mcp_enabled[*i]) + .map(|(_, (name, _))| name.to_string()) + .collect(); + let config = cdcx_core::config::McpConfig { + services, + allow_dangerous: self.mcp_dangerous, + }; + config.save().map_err(|e| e.to_string()) + } + pub fn draw(&self, frame: &mut Frame, area: Rect, colors: &ThemeColors) { - let width = 52u16; - let height = 20u16; + let width = 56u16; + let height = 26u16; let x = area.x + area.width.saturating_sub(width) / 2; let y = area.y + area.height.saturating_sub(height) / 2; let modal = Rect::new(x, y, width.min(area.width), height.min(area.height)); @@ -226,33 +289,35 @@ impl SettingsPanel { let inner = block.inner(modal); frame.render_widget(block, modal); - let [settings_area, _, preview_area, _, footer_area] = Layout::vertical([ - Constraint::Length(5), - Constraint::Length(1), + let [settings_area, preview_area, _, footer_area] = Layout::vertical([ + Constraint::Length(14), // 3 TUI + 1 divider + 8 services + 1 dangerous + 1 pad Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), ]) .areas(inner); - // Settings rows let mut lines = Vec::new(); - for (i, row) in SettingsRow::ALL.iter().enumerate() { - let is_selected = i == self.selected; - let label = row.label(); - let value = match row { - SettingsRow::Theme => self.selected_theme_name().to_string(), - SettingsRow::TickerSpeed => self.selected_ticker_speed_label().to_string(), - SettingsRow::TickRate => { - let (ms, desc) = TICK_RATES[self.tick_rate_idx]; - if ms == self.original_tick_rate_ms { - desc.to_string() - } else { - format!("{} *", desc) - } + + // --- TUI settings (rows 0-2) --- + let tui_rows: &[(&str, String)] = &[ + ("Theme", self.selected_theme_name().to_string()), + ( + "Ticker Tape", + self.selected_ticker_speed_label().to_string(), + ), + ("Tick Rate", { + let (ms, desc) = TICK_RATES[self.tick_rate_idx]; + if ms == self.original_tick_rate_ms { + desc.to_string() + } else { + format!("{} *", desc) } - }; + }), + ]; + for (i, (label, value)) in tui_rows.iter().enumerate() { + let is_selected = i == self.selected; let arrow_style = if is_selected { Style::default().fg(colors.accent) } else { @@ -270,76 +335,156 @@ impl SettingsPanel { } else { Style::default().fg(colors.fg) }; - lines.push(Line::from(vec![ Span::styled(if is_selected { " \u{25b6} " } else { " " }, arrow_style), Span::styled(format!("{:<12}", label), label_style), Span::styled(" \u{25c0} ", arrow_style), - Span::styled(value, value_style), + Span::styled(value.clone(), value_style), Span::styled(" \u{25b6}", arrow_style), ])); } - // Tick rate note - if self.selected_tick_rate_ms() != self.original_tick_rate_ms { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " * takes effect on next launch", - Style::default().fg(colors.muted), - ))); + // --- MCP section divider --- + lines.push(Line::from(Span::styled( + " \u{2500}\u{2500} MCP Services \u{2500}\u{2500}", + Style::default().fg(colors.muted), + ))); + + // --- MCP service checkboxes (rows TUI_ROW_COUNT .. TUI_ROW_COUNT+8) --- + for (i, (name, desc)) in MCP_SERVICES.iter().enumerate() { + let row_idx = TUI_ROW_COUNT + i; + let is_selected = row_idx == self.selected; + let checked = self.mcp_enabled[i]; + + let checkbox = if checked { "[\u{2713}]" } else { "[ ]" }; + let check_style = if checked { + Style::default().fg(colors.positive) + } else { + Style::default().fg(colors.muted) + }; + let label_style = if is_selected { + Style::default() + .fg(colors.header) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors.fg) + }; + let desc_style = Style::default().fg(colors.muted); + + lines.push(Line::from(vec![ + Span::styled( + if is_selected { " \u{25b6} " } else { " " }, + if is_selected { + Style::default().fg(colors.accent) + } else { + Style::default().fg(colors.muted) + }, + ), + Span::styled(checkbox, check_style), + Span::styled(format!(" {:<10}", name), label_style), + Span::styled(*desc, desc_style), + ])); } - frame.render_widget(Paragraph::new(lines), settings_area); + // --- allow_dangerous toggle (last MCP row) --- + { + let row_idx = TUI_ROW_COUNT + MCP_SERVICES.len(); + let is_selected = row_idx == self.selected; + let checked = self.mcp_dangerous; - // Live theme preview - let preview_theme = self.selected_theme(); - let c = &preview_theme.colors; - let preview_lines = vec![ - Line::from(Span::styled(" Preview:", Style::default().fg(c.muted))), - Line::from(""), - Line::from(vec![ + let checkbox = if checked { "[\u{2713}]" } else { "[ ]" }; + let check_style = if checked { + Style::default().fg(colors.negative) + } else { + Style::default().fg(colors.muted) + }; + let label_style = if is_selected { + Style::default() + .fg(colors.header) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors.fg) + }; + + lines.push(Line::from(vec![ Span::styled( - " BTC_USDT ", - Style::default().fg(c.accent).add_modifier(Modifier::BOLD), + if is_selected { " \u{25b6} " } else { " " }, + if is_selected { + Style::default().fg(colors.accent) + } else { + Style::default().fg(colors.muted) + }, ), - Span::styled("67,234.50 ", Style::default().fg(c.fg)), - Span::styled("+2.34% ", Style::default().fg(c.positive)), - Span::styled("Vol: 1.2B", Style::default().fg(c.volume)), - ]), - Line::from(vec![ - Span::styled(" ETH_USDT ", Style::default().fg(c.fg)), - Span::styled(" 3,456.78 ", Style::default().fg(c.fg)), - Span::styled("-0.87% ", Style::default().fg(c.negative)), - Span::styled("Vol: 892M", Style::default().fg(c.volume)), - ]), + Span::styled(checkbox, check_style), + Span::styled(" allow_dangerous", label_style), + ])); + } + + frame.render_widget(Paragraph::new(lines), settings_area); + + // Theme preview + if preview_area.height >= 5 { + let preview_theme = self.selected_theme(); + let c = &preview_theme.colors; + let preview_lines = vec![ + Line::from(Span::styled( + " \u{2500}\u{2500} Preview \u{2500}\u{2500}", + Style::default().fg(c.muted), + )), + Line::from(vec![ + Span::styled( + " BTC_USDT ", + Style::default().fg(c.accent).add_modifier(Modifier::BOLD), + ), + Span::styled("12,345.67 ", Style::default().fg(c.fg)), + Span::styled("+2.34% ", Style::default().fg(c.positive)), + Span::styled("Vol: 1.2B", Style::default().fg(c.volume)), + ]), + Line::from(vec![ + Span::styled(" ETH_USDT ", Style::default().fg(c.fg)), + Span::styled(" 1,234.56 ", Style::default().fg(c.fg)), + Span::styled("-0.87% ", Style::default().fg(c.negative)), + Span::styled("Vol: 892M", Style::default().fg(c.volume)), + ]), + Line::from(vec![ + Span::styled(" SOL_USDT ", Style::default().fg(c.fg)), + Span::styled(" 123.45 ", Style::default().fg(c.fg)), + Span::styled("+5.12% ", Style::default().fg(c.positive)), + Span::styled("Vol: 445M", Style::default().fg(c.volume)), + ]), + Line::from(vec![ + Span::styled(" Status: ", Style::default().fg(c.muted)), + Span::styled("LIVE", Style::default().fg(c.positive)), + Span::styled(" | ", Style::default().fg(c.border)), + Span::styled( + "PROD", + Style::default().fg(c.status_bar_fg).bg(c.status_bar_bg), + ), + ]), + ]; + frame.render_widget(Paragraph::new(preview_lines), preview_area); + } + + // Footer — context-sensitive + let footer = if self.is_mcp_row() { Line::from(vec![ - Span::styled(" SOL_USDT ", Style::default().fg(c.fg)), - Span::styled(" 178.92 ", Style::default().fg(c.fg)), - Span::styled("+5.12% ", Style::default().fg(c.positive)), - Span::styled("Vol: 445M", Style::default().fg(c.volume)), - ]), - Line::from(""), + Span::styled(" Space", Style::default().fg(colors.accent)), + Span::styled(":toggle ", Style::default().fg(colors.muted)), + Span::styled("Enter", Style::default().fg(colors.accent)), + Span::styled(":save ", Style::default().fg(colors.muted)), + Span::styled("Esc", Style::default().fg(colors.accent)), + Span::styled(":discard", Style::default().fg(colors.muted)), + ]) + } else { Line::from(vec![ - Span::styled(" Status: ", Style::default().fg(c.muted)), - Span::styled("LIVE", Style::default().fg(c.positive)), - Span::styled(" | ", Style::default().fg(c.border)), - Span::styled( - "PROD", - Style::default().fg(c.status_bar_fg).bg(c.status_bar_bg), - ), - ]), - ]; - frame.render_widget(Paragraph::new(preview_lines), preview_area); - - // Footer - let footer = Line::from(vec![ - Span::styled(" Enter", Style::default().fg(colors.accent)), - Span::styled(":save ", Style::default().fg(colors.muted)), - Span::styled("Esc", Style::default().fg(colors.accent)), - Span::styled(":close ", Style::default().fg(colors.muted)), - Span::styled("\u{2190}\u{2192}", Style::default().fg(colors.accent)), - Span::styled(":change", Style::default().fg(colors.muted)), - ]); + Span::styled(" \u{2190}\u{2192}", Style::default().fg(colors.accent)), + Span::styled(":change ", Style::default().fg(colors.muted)), + Span::styled("Enter", Style::default().fg(colors.accent)), + Span::styled(":save ", Style::default().fg(colors.muted)), + Span::styled("Esc", Style::default().fg(colors.accent)), + Span::styled(":discard", Style::default().fg(colors.muted)), + ]) + }; frame.render_widget(Paragraph::new(footer), footer_area); } } @@ -526,26 +671,26 @@ mod tests { fn test_navigate_rows() { let mut panel = SettingsPanel::new("terminal-pro", 250, 2); assert_eq!(panel.selected, 0); + // Navigate down through all rows + for expected in 1..TOTAL_ROWS { + panel.on_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + assert_eq!(panel.selected, expected); + } + // Clamped at last row panel.on_key(KeyEvent::new( KeyCode::Down, crossterm::event::KeyModifiers::NONE, )); - assert_eq!(panel.selected, 1); - panel.on_key(KeyEvent::new( - KeyCode::Down, - crossterm::event::KeyModifiers::NONE, - )); - assert_eq!(panel.selected, 2); - panel.on_key(KeyEvent::new( - KeyCode::Down, - crossterm::event::KeyModifiers::NONE, - )); - assert_eq!(panel.selected, 2); // clamped + assert_eq!(panel.selected, TOTAL_ROWS - 1); + // Navigate back up panel.on_key(KeyEvent::new( KeyCode::Up, crossterm::event::KeyModifiers::NONE, )); - assert_eq!(panel.selected, 1); + assert_eq!(panel.selected, TOTAL_ROWS - 2); } // ---- Watchlist persistence (Issue #23) ---- diff --git a/plugins/cdcx-cli/.claude-plugin/README.md b/plugins/cdcx-cli/.claude-plugin/README.md index fe02b9d..1f3b228 100644 --- a/plugins/cdcx-cli/.claude-plugin/README.md +++ b/plugins/cdcx-cli/.claude-plugin/README.md @@ -12,25 +12,33 @@ cdcx market ticker BTC_USDT ## Enable Trading -To unlock trading and account tools, update the MCP server configuration: +To unlock trading and account tools, use `cdcx mcp config`: -1. Edit the `mcpServers.cdcx.args` in your MCP settings (project `.claude/settings.json` or user `~/.claude/settings.json`) -2. Change the args to include additional services: - -```json -["mcp", "--services", "market,trade,account"] +```bash +cdcx mcp config --enable trade +cdcx mcp config --enable account ``` -3. Add your credentials in the `env` section: +Then set up your API credentials: -```json -{ - "CDC_API_KEY": "your-key", - "CDC_API_SECRET": "your-secret" -} +```bash +cdcx setup ``` -Or run `cdcx setup` to configure credentials interactively. +Or set `CDC_API_KEY` and `CDC_API_SECRET` environment variables. + +Your configuration is saved to `~/.config/cdcx/mcp.toml` and persists across plugin updates. + +## Managing Services + +```bash +cdcx mcp config # Show current configuration +cdcx mcp config --enable trade # Enable a service group +cdcx mcp config --disable funding # Disable a service group +cdcx mcp config --allow-dangerous # Enable dangerous operations +cdcx mcp config --no-dangerous # Disable dangerous operations +cdcx mcp config --reset # Reset to defaults +``` ## Available Services @@ -45,23 +53,6 @@ Or run `cdcx setup` to configure credentials interactively. | `funding` | Yes | Withdrawals (requires `--allow-dangerous`) | | `fiat` | Yes | Fiat operations (requires `--allow-dangerous`) | -## Example Configurations - -**Read-only (default):** -```json -["mcp", "--services", "market"] -``` - -**Trading agent:** -```json -["mcp", "--services", "market,trade,account"] -``` - -**Full access:** -```json -["mcp", "--services", "market,trade,account,advanced,margin,staking,funding,fiat", "--allow-dangerous"] -``` - ## Links - [GitHub](https://github.com/crypto-com/cdcx-cli) diff --git a/plugins/cdcx-cli/.claude-plugin/plugin.json b/plugins/cdcx-cli/.claude-plugin/plugin.json index c7f1324..8b96afd 100644 --- a/plugins/cdcx-cli/.claude-plugin/plugin.json +++ b/plugins/cdcx-cli/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "cdcx-cli", "version": "1.2.2", - "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Available --services: market (default), trade, account, advanced, margin, staking, funding, fiat. Update args in MCP settings to expand capabilities.", + "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Run 'cdcx mcp config --enable trade' to expand capabilities.", "author": { "name": "Crypto.com", "url": "https://github.com/crypto-com/cdcx-cli" @@ -36,7 +36,7 @@ "mcpServers": { "cdcx": { "command": "npx", - "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"], + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp"], "env": { "CDC_API_KEY": "${user_config.api_key}", "CDC_API_SECRET": "${user_config.api_secret}" diff --git a/plugins/cdcx-cli/.codex-plugin/plugin.json b/plugins/cdcx-cli/.codex-plugin/plugin.json index 521cad2..04ecbcf 100644 --- a/plugins/cdcx-cli/.codex-plugin/plugin.json +++ b/plugins/cdcx-cli/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "cdcx-cli", "version": "1.2.2", - "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Available --services: market (default), trade, account, advanced, margin, staking, funding, fiat.", + "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Run 'cdcx mcp config --enable trade' to expand capabilities.", "author": { "name": "Crypto.com", "url": "https://github.com/crypto-com/cdcx-cli" diff --git a/plugins/cdcx-cli/.cursor-plugin/plugin.json b/plugins/cdcx-cli/.cursor-plugin/plugin.json index e25bfc6..68b95ef 100644 --- a/plugins/cdcx-cli/.cursor-plugin/plugin.json +++ b/plugins/cdcx-cli/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "cdcx-cli", "version": "1.2.2", - "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Available --services: market (default), trade, account, advanced, margin, staking, funding, fiat. Update args in MCP settings to expand capabilities.", + "description": "Agent-first CLI for the Crypto.com Exchange API. Market data by default; live trading opt-in via API keys. Run 'cdcx mcp config --enable trade' to expand capabilities.", "author": { "name": "Crypto.com", "url": "https://github.com/crypto-com/cdcx-cli" @@ -22,7 +22,7 @@ "mcpServers": { "cdcx": { "command": "npx", - "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp"] } } } diff --git a/plugins/cdcx-cli/.mcp.json b/plugins/cdcx-cli/.mcp.json index 6a1b2a7..b961e7e 100644 --- a/plugins/cdcx-cli/.mcp.json +++ b/plugins/cdcx-cli/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "cdcx": { "command": "npx", - "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp"] } } } diff --git a/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md b/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md index 4f9c0c5..732e0ca 100644 --- a/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md +++ b/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md @@ -24,13 +24,8 @@ Every cdcx MCP tool is classified into one of four tiers: Agent can observe the market but cannot touch the account. -``` -cdcx mcp --services market -``` - -MCP config: -```json -{"command":"npx","args":["-y","@cryptocom/cdcx-cli@latest","mcp","--services","market"]} +```bash +cdcx mcp config --reset # ensures only market is enabled ``` Use for: market research agents, price alerts, chart commentary. @@ -39,8 +34,8 @@ Use for: market research agents, price alerts, chart commentary. Agent can read live market data plus read authenticated account info; any actual trading is paper-only and handled CLI-side (not via MCP). -``` -cdcx mcp --services market,account +```bash +cdcx mcp config --enable account ``` Pair the MCP server with shell access to `cdcx paper` for simulated execution. Paper has no MCP tools; execution stays outside the agent's tool surface. @@ -51,11 +46,11 @@ Use for: strategy research with real account context, backtest harnesses. Agent can place and cancel real orders, but every mutation requires explicit acknowledgement from the host application (human-in-the-loop). -``` -cdcx mcp --services market,account,trade,advanced +```bash +cdcx mcp config --enable account,trade,advanced ``` -The agent calls `cdcx_trade_order`; the host MUST echo the intent to the human and only pass `acknowledged: true` once approved. Dangerous tools (`cancel-all`, `withdraw`) are refused at the server since `--allow-dangerous` is not set. +The agent calls `cdcx_trade_order`; the host MUST echo the intent to the human and only pass `acknowledged: true` once approved. Dangerous tools (`cancel-all`, `withdraw`) are refused at the server since `allow_dangerous` is not set. Use for: semi-automated trading with human oversight, production with guardrails. @@ -63,8 +58,9 @@ Use for: semi-automated trading with human oversight, production with guardrails Agent executes anything without per-call confirmation. Dangerous tier is available. -``` -cdcx mcp --services market,account,trade,advanced,funding --allow-dangerous +```bash +cdcx mcp config --enable account,trade,advanced,funding +cdcx mcp config --allow-dangerous ``` Even at level 3, `mutate` tools still require `acknowledged: true` in the tool arguments — but an autonomous agent will pass it automatically without a human prompt. `--allow-dangerous` additionally unlocks cancel-all and withdrawals. @@ -81,15 +77,15 @@ Use for: fully automated strategies with external risk controls (position limits ## Service Group Reference -Valid MCP service groups: `market, account, trade, advanced, margin, staking, funding, fiat, otc, stream, all`. +Valid MCP service groups: `market`, `account`, `trade`, `advanced`, `margin`, `staking`, `funding`, `fiat`, `otc`, `stream`. - `account` also exposes `history` (orders/trades/transactions) - `funding` maps to the `wallet` CLI group (deposit/withdraw/networks) - `paper` is **not** an MCP group — paper trading is CLI-only -- `all` includes everything above +- `cdcx mcp config --enable all` is a shortcut to enable everything ## Notes - Withdrawals (`funding withdraw`) should only be unlocked at Level 3 and only with `--allow-dangerous`; they are the highest-risk action available - You can run multiple cdcx MCP servers side by side with different autonomy levels (one agent at level 1, another at level 2) -- `--allow-dangerous` only affects the dangerous tier. Mutate tools always need `acknowledged: true` regardless +- `allow_dangerous` only affects the dangerous tier. Mutate tools always need `acknowledged: true` regardless diff --git a/schemas/configs/mcp.json b/schemas/configs/mcp.json new file mode 100644 index 0000000..fb0f944 --- /dev/null +++ b/schemas/configs/mcp.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/crypto-com/cdcx-cli/refs/heads/main/schemas/configs/mcp.json", + "title": "cdcx MCP config", + "description": "MCP server preferences (~/.config/cdcx/mcp.toml)", + "type": "object", + "properties": { + "services": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "market", + "account", + "trade", + "advanced", + "margin", + "staking", + "funding", + "fiat", + "otc", + "stream" + ] + }, + "default": ["market"], + "description": "Service groups to expose via MCP tools" + }, + "allow_dangerous": { + "type": "boolean", + "default": false, + "description": "Allow dangerous operations (cancel-all, withdrawals, etc.)" + } + }, + "additionalProperties": false +}