diff --git a/src/cli/completer.rs b/src/cli/completer.rs index 0a6e732..6261400 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -5,9 +5,9 @@ use crate::utils::list_file_names; use crate::vault::Vault; use clap_complete::{CompletionCandidate, Shell, generate}; use clap_complete_nushell::Nushell; -use std::env; use std::ffi::OsStr; use std::io; +use std::{env, fs}; const COYOTE_CLI_NAME: &str = "coyote"; @@ -134,6 +134,34 @@ pub(super) fn session_completer(current: &OsStr) -> Vec { .collect() } +pub(super) fn mcp_server_completer(current: &OsStr) -> Vec { + let cur = current.to_string_lossy(); + let content = match fs::read_to_string(paths::mcp_config_file()) { + Ok(c) => c, + Err(_) => return vec![], + }; + let json: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return vec![], + }; + let servers = match json.get("mcpServers").and_then(|v| v.as_object()) { + Some(s) => s, + None => return vec![], + }; + + servers + .iter() + .filter(|(_, v)| { + v.get("type") + .and_then(|t| t.as_str()) + .map(|t| t == "http" || t == "sse") + .unwrap_or(false) + }) + .filter(|(k, _)| k.starts_with(&*cur)) + .map(|(k, _)| CompletionCandidate::new(k)) + .collect() +} + pub(super) fn secrets_completer(current: &OsStr) -> Vec { let cur = current.to_string_lossy(); match load_app_config_for_completion() { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3744fb7..5f35727 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,8 @@ mod completer; use crate::cli::completer::{ - ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer, - role_completer, secrets_completer, session_completer, + ShellCompletion, agent_completer, macro_completer, mcp_server_completer, model_completer, + rag_completer, role_completer, secrets_completer, session_completer, }; use crate::config::{AssetCategory, InstallFilter, MemoryScope}; use anyhow::{Context, Result}; @@ -171,6 +171,9 @@ pub struct Cli { /// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name) #[arg(long, exclusive = true, value_name = "CLIENT_NAME")] pub authenticate: Option>, + /// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name) + #[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))] + pub auth_mcp: Option, /// Generate static shell completion scripts #[arg(long, value_name = "SHELL", value_enum)] pub completions: Option, diff --git a/src/main.rs b/src/main.rs index d97eda1..2f85205 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,11 +28,12 @@ use crate::config::{ install_builtins, list_agents, load_env_file, macro_execute, sync_models, }; use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail}; +use crate::mcp::McpServersConfig; use crate::render::{prompt_theme, render_error}; use crate::repl::Repl; use crate::utils::*; -use crate::vault::Vault; -use anyhow::{Result, anyhow, bail}; +use crate::vault::{Vault, interpolate_secrets}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{CommandFactory, Parser}; use clap_complete::CompleteEnv; use client::ClientConfig; @@ -120,6 +121,48 @@ async fn main() -> Result<()> { return Ok(()); } + if let Some(server_name) = &cli.auth_mcp { + let cfg = Config::load_with_interpolation(true).await?; + let app_config = AppConfig::from_config(cfg)?; + let vault = Vault::init(&app_config)?; + let mcp_path = paths::mcp_config_file(); + if !mcp_path.exists() { + bail!( + "No MCP configuration file found at '{}'", + mcp_path.display() + ); + } + + let raw = tokio::fs::read_to_string(&mcp_path) + .await + .with_context(|| format!("Failed to read MCP config at '{}'", mcp_path.display()))?; + + let (content, missing) = interpolate_secrets(&raw, &vault)?; + if !missing.is_empty() { + bail!( + "MCP config references vault secrets that are missing: {:?}", + missing + ); + } + + let mcp_config: McpServersConfig = + serde_json::from_str(&content).context("Failed to parse MCP config file")?; + let spec = mcp_config + .mcp_servers + .get(server_name.as_str()) + .ok_or_else(|| anyhow!("MCP server '{server_name}' not found in mcp.json"))?; + if !spec.is_remote() { + bail!( + "MCP server '{server_name}' is a stdio server; OAuth is only supported for http/sse servers" + ); + } + + let url = spec.url.as_deref().expect("validated: remote spec has url"); + mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?; + + return Ok(()); + } + if vault_flags { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?;