feat: Support managing MCP servers from the CLI directly
This commit is contained in:
+164
-1
@@ -13,6 +13,20 @@ use is_terminal::IsTerminal;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::{Read, stdin};
|
use std::io::{Read, stdin};
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum McpTransportArg {
|
||||||
|
Stdio,
|
||||||
|
Http,
|
||||||
|
Sse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum McpScopeArg {
|
||||||
|
#[default]
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -41,10 +55,15 @@ use std::io::{Read, stdin};
|
|||||||
"list_skills", "skill", "tail_logs", "completions", "update",
|
"list_skills", "skill", "tail_logs", "completions", "update",
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
group(
|
||||||
|
ArgGroup::new("mcp-action")
|
||||||
|
.args(["mcp_add", "mcp_remove", "mcp_list", "mcp_get"])
|
||||||
|
.multiple(false)
|
||||||
|
),
|
||||||
)]
|
)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Input text
|
/// Input text
|
||||||
#[arg(trailing_var_arg = true)]
|
#[arg(allow_hyphen_values = true)]
|
||||||
text: Vec<String>,
|
text: Vec<String>,
|
||||||
|
|
||||||
/// Select a LLM model
|
/// Select a LLM model
|
||||||
@@ -224,6 +243,57 @@ pub struct Cli {
|
|||||||
#[arg(long, exclusive = true, value_name = "SERVER_NAME", help_heading = "Authentication", add = ArgValueCompleter::new(mcp_server_completer))]
|
#[arg(long, exclusive = true, value_name = "SERVER_NAME", help_heading = "Authentication", add = ArgValueCompleter::new(mcp_server_completer))]
|
||||||
pub auth_mcp: Option<String>,
|
pub auth_mcp: Option<String>,
|
||||||
|
|
||||||
|
/// Add an MCP server. Use `-- <cmd> [args...]` for stdio, or `--url <URL>` for http/sse.
|
||||||
|
#[arg(long, value_name = "NAME", help_heading = "MCP Servers")]
|
||||||
|
pub mcp_add: Option<String>,
|
||||||
|
/// Remove an MCP server by name
|
||||||
|
#[arg(long, value_name = "NAME", help_heading = "MCP Servers", add = ArgValueCompleter::new(mcp_server_completer))]
|
||||||
|
pub mcp_remove: Option<String>,
|
||||||
|
/// List all configured MCP servers (user + workspace scopes)
|
||||||
|
#[arg(long, help_heading = "MCP Servers")]
|
||||||
|
pub mcp_list: bool,
|
||||||
|
/// Show the JSON config for one MCP server
|
||||||
|
#[arg(long, value_name = "NAME", help_heading = "MCP Servers", add = ArgValueCompleter::new(mcp_server_completer))]
|
||||||
|
pub mcp_get: Option<String>,
|
||||||
|
/// Transport for --mcp-add: stdio (default when `--` present), http, or sse
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
value_name = "TRANSPORT",
|
||||||
|
help_heading = "MCP Servers"
|
||||||
|
)]
|
||||||
|
pub transport: Option<McpTransportArg>,
|
||||||
|
/// URL for http/sse MCP server (used with --mcp-add)
|
||||||
|
#[arg(long, value_name = "URL", help_heading = "MCP Servers")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Scope for MCP config: user (~/.config/coyote/functions/mcp.json) or workspace (./.coyote/mcp.json). Default: user
|
||||||
|
#[arg(long, value_enum, value_name = "SCOPE", help_heading = "MCP Servers")]
|
||||||
|
pub scope: Option<McpScopeArg>,
|
||||||
|
/// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE
|
||||||
|
#[arg(long, value_name = "KEY=VALUE", help_heading = "MCP Servers")]
|
||||||
|
pub env: Vec<String>,
|
||||||
|
/// HTTP header for http/sse MCP server (repeatable): --header "Name: Value"
|
||||||
|
#[arg(long, value_name = "HEADER", help_heading = "MCP Servers")]
|
||||||
|
pub header: Vec<String>,
|
||||||
|
/// Working directory for stdio MCP server
|
||||||
|
#[arg(long, value_name = "PATH", value_hint = ValueHint::AnyPath, help_heading = "MCP Servers")]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// OAuth client ID for http/sse MCP server
|
||||||
|
#[arg(long, value_name = "ID", help_heading = "MCP Servers")]
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
/// OAuth client secret for http/sse MCP server (use {{NAME}} to reference a vault secret)
|
||||||
|
#[arg(long, value_name = "SECRET", help_heading = "MCP Servers")]
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
/// OAuth callback port for http/sse MCP server
|
||||||
|
#[arg(long, value_name = "PORT", help_heading = "MCP Servers")]
|
||||||
|
pub callback_port: Option<u16>,
|
||||||
|
/// OAuth redirect host for http/sse MCP server
|
||||||
|
#[arg(long, value_name = "HOST", help_heading = "MCP Servers")]
|
||||||
|
pub redirect_host: Option<String>,
|
||||||
|
/// Overwrite an existing MCP server (with --mcp-add) or skip confirmation (with --mcp-remove)
|
||||||
|
#[arg(long, help_heading = "MCP Servers")]
|
||||||
|
pub mcp_force: bool,
|
||||||
|
|
||||||
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||||
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
|
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
|
||||||
pub sandbox: Option<Option<String>>,
|
pub sandbox: Option<Option<String>>,
|
||||||
@@ -254,6 +324,15 @@ pub struct Cli {
|
|||||||
/// Generate static shell completion scripts
|
/// Generate static shell completion scripts
|
||||||
#[arg(long, value_name = "SHELL", value_enum, help_heading = "Shell")]
|
#[arg(long, value_name = "SHELL", value_enum, help_heading = "Shell")]
|
||||||
pub completions: Option<ShellCompletion>,
|
pub completions: Option<ShellCompletion>,
|
||||||
|
|
||||||
|
/// Stdio command for --mcp-add: everything after `--` is passed to the server verbatim
|
||||||
|
#[arg(
|
||||||
|
last = true,
|
||||||
|
allow_hyphen_values = true,
|
||||||
|
value_name = "CMD",
|
||||||
|
help_heading = "MCP Servers"
|
||||||
|
)]
|
||||||
|
pub mcp_command: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
@@ -633,4 +712,88 @@ mod tests {
|
|||||||
fn parse_sandbox_is_exclusive() {
|
fn parse_sandbox_is_exclusive() {
|
||||||
assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err());
|
assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_add_stdio_with_trailing_command() {
|
||||||
|
let cli = parse(&[
|
||||||
|
"--mcp-add",
|
||||||
|
"myserver",
|
||||||
|
"--",
|
||||||
|
"npx",
|
||||||
|
"some-server",
|
||||||
|
"--flag",
|
||||||
|
"arg1",
|
||||||
|
]);
|
||||||
|
assert_eq!(cli.mcp_add, Some("myserver".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
cli.mcp_command,
|
||||||
|
vec!["npx", "some-server", "--flag", "arg1"]
|
||||||
|
);
|
||||||
|
assert!(cli.text.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_add_stdio_with_env_and_command() {
|
||||||
|
let cli = parse(&[
|
||||||
|
"--mcp-add",
|
||||||
|
"s",
|
||||||
|
"--env",
|
||||||
|
"API_KEY={{API_KEY}}",
|
||||||
|
"--env",
|
||||||
|
"MODE=dev",
|
||||||
|
"--",
|
||||||
|
"npx",
|
||||||
|
"srv",
|
||||||
|
]);
|
||||||
|
assert_eq!(cli.mcp_add, Some("s".to_string()));
|
||||||
|
assert_eq!(cli.env, vec!["API_KEY={{API_KEY}}", "MODE=dev"]);
|
||||||
|
assert_eq!(cli.mcp_command, vec!["npx", "srv"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_add_http_with_header() {
|
||||||
|
let cli = parse(&[
|
||||||
|
"--mcp-add",
|
||||||
|
"notion",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--url",
|
||||||
|
"https://mcp.notion.com/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer {{NOTION_TOKEN}}",
|
||||||
|
]);
|
||||||
|
assert_eq!(cli.mcp_add, Some("notion".to_string()));
|
||||||
|
assert!(matches!(cli.transport, Some(McpTransportArg::Http)));
|
||||||
|
assert_eq!(cli.url, Some("https://mcp.notion.com/mcp".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
cli.header,
|
||||||
|
vec!["Authorization: Bearer {{NOTION_TOKEN}}"]
|
||||||
|
);
|
||||||
|
assert!(cli.mcp_command.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_list_flag() {
|
||||||
|
let cli = parse(&["--mcp-list"]);
|
||||||
|
assert!(cli.mcp_list);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_scope_workspace() {
|
||||||
|
let cli = parse(&["--mcp-list", "--scope", "workspace"]);
|
||||||
|
assert!(cli.mcp_list);
|
||||||
|
assert!(matches!(cli.scope, Some(McpScopeArg::Workspace)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mcp_action_group_is_exclusive() {
|
||||||
|
assert!(Cli::try_parse_from(["coyote", "--mcp-list", "--mcp-get", "foo"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trailing_text_unchanged_without_dash_dash() {
|
||||||
|
let cli = parse(&["hello", "world"]);
|
||||||
|
assert_eq!(cli.text, vec!["hello", "world"]);
|
||||||
|
assert!(cli.mcp_command.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -196,6 +196,18 @@ async fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mcp_action =
|
||||||
|
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
|
||||||
|
if mcp_action {
|
||||||
|
let cfg = Config::load_with_interpolation(true).await?;
|
||||||
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
|
let vault = Vault::init(&app_config)?;
|
||||||
|
|
||||||
|
mcp::manage::handle(&cli, &vault)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if vault_flags {
|
if vault_flags {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
use crate::cli::{Cli, McpScopeArg, McpTransportArg};
|
||||||
|
use crate::config::{ensure_parent_exists, paths};
|
||||||
|
use crate::mcp::{JsonField, McpOAuthConfig, McpServer, McpServersConfig, McpTransportType};
|
||||||
|
use crate::vault::{SECRET_RE, Vault};
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use indexmap::{IndexMap, IndexSet};
|
||||||
|
use inquire::Confirm;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
impl From<McpTransportArg> for McpTransportType {
|
||||||
|
fn from(value: McpTransportArg) -> Self {
|
||||||
|
match value {
|
||||||
|
McpTransportArg::Stdio => McpTransportType::Stdio,
|
||||||
|
McpTransportArg::Http => McpTransportType::Http,
|
||||||
|
McpTransportArg::Sse => McpTransportType::Sse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(cli: &Cli, vault: &Vault) -> Result<()> {
|
||||||
|
if cli.mcp_list {
|
||||||
|
return handle_list(cli.scope);
|
||||||
|
}
|
||||||
|
if let Some(name) = &cli.mcp_get {
|
||||||
|
return handle_get(name, cli.scope);
|
||||||
|
}
|
||||||
|
if let Some(name) = &cli.mcp_remove {
|
||||||
|
return handle_remove(name, cli.scope, cli.mcp_force);
|
||||||
|
}
|
||||||
|
if let Some(name) = &cli.mcp_add {
|
||||||
|
return handle_add(cli, name, vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list(scope: Option<McpScopeArg>) -> Result<()> {
|
||||||
|
let show_user = scope != Some(McpScopeArg::Workspace);
|
||||||
|
let show_workspace = scope != Some(McpScopeArg::User);
|
||||||
|
|
||||||
|
if show_user {
|
||||||
|
let user_path = paths::mcp_config_file();
|
||||||
|
let user_cfg = load_config_raw(&user_path)?;
|
||||||
|
println!("User ({})", user_path.display());
|
||||||
|
print_server_list(&user_cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_workspace {
|
||||||
|
match paths::workspace_mcp_config_file() {
|
||||||
|
Some(ws_path) => {
|
||||||
|
let ws_cfg = load_config_raw(&ws_path)?;
|
||||||
|
if show_user {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!("Workspace ({})", ws_path.display());
|
||||||
|
print_server_list(&ws_cfg);
|
||||||
|
}
|
||||||
|
None if scope == Some(McpScopeArg::Workspace) => {
|
||||||
|
println!("Workspace: no mcp.json found in current directory");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_server_list(cfg: &McpServersConfig) {
|
||||||
|
if cfg.mcp_servers.is_empty() {
|
||||||
|
println!(" (none)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name_width = cfg.mcp_servers.keys().map(String::len).max().unwrap_or(0);
|
||||||
|
for (name, spec) in &cfg.mcp_servers {
|
||||||
|
let transport = match spec.transport_type {
|
||||||
|
McpTransportType::Stdio => "stdio",
|
||||||
|
McpTransportType::Http => "http",
|
||||||
|
McpTransportType::Sse => "sse",
|
||||||
|
};
|
||||||
|
let target = spec.url.clone().unwrap_or_else(|| {
|
||||||
|
let cmd = spec.command.clone().unwrap_or_default();
|
||||||
|
let args = spec.args.as_ref().map(|a| a.join(" ")).unwrap_or_default();
|
||||||
|
if args.is_empty() {
|
||||||
|
cmd
|
||||||
|
} else {
|
||||||
|
format!("{cmd} {args}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
" {name:<name_width$} {transport:<5} {target}",
|
||||||
|
name_width = name_width
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get(name: &str, scope: Option<McpScopeArg>) -> Result<()> {
|
||||||
|
let (path, cfg) = load_for_scope_or_search(name, scope)?;
|
||||||
|
let spec = cfg
|
||||||
|
.mcp_servers
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
|
||||||
|
let pretty =
|
||||||
|
serde_json::to_string_pretty(spec).context("failed to serialize MCP server config")?;
|
||||||
|
println!("# {}", path.display());
|
||||||
|
println!("{pretty}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_remove(name: &str, scope: Option<McpScopeArg>, force: bool) -> Result<()> {
|
||||||
|
let (path, mut cfg) = load_for_scope_or_search(name, scope)?;
|
||||||
|
if !force {
|
||||||
|
let ok = Confirm::new(&format!(
|
||||||
|
"Remove MCP server '{name}' from {}?",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()?;
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
println!("Aborted.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.mcp_servers.shift_remove(name);
|
||||||
|
save_config(&path, &cfg)?;
|
||||||
|
println!("✓ Removed MCP server '{name}' from {}", path.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_add(cli: &Cli, name: &str, vault: &Vault) -> Result<()> {
|
||||||
|
validate_name(name)?;
|
||||||
|
let server = build_server(cli)?;
|
||||||
|
server.validate(name)?;
|
||||||
|
|
||||||
|
let scope = cli.scope.unwrap_or_default();
|
||||||
|
let path = write_path_for_scope(scope);
|
||||||
|
let mut cfg = load_config_raw(&path)?;
|
||||||
|
|
||||||
|
if cfg.mcp_servers.contains_key(name) && !cli.mcp_force {
|
||||||
|
let ok = Confirm::new(&format!(
|
||||||
|
"MCP server '{name}' already exists in {}. Overwrite?",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()?;
|
||||||
|
if !ok {
|
||||||
|
println!("Aborted. Use --mcp-force to overwrite without prompting.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provision_secrets(cli, vault)?;
|
||||||
|
|
||||||
|
cfg.mcp_servers.insert(name.to_string(), server);
|
||||||
|
save_config(&path, &cfg)?;
|
||||||
|
println!("✓ Added MCP server '{name}' to {}", path.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("MCP server name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
bail!("Invalid MCP server name '{name}': only letters, digits, '-', and '_' are allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_server(cli: &Cli) -> Result<McpServer> {
|
||||||
|
let has_command = !cli.mcp_command.is_empty();
|
||||||
|
let has_url = cli.url.is_some();
|
||||||
|
|
||||||
|
let transport = cli
|
||||||
|
.transport
|
||||||
|
.map(McpTransportType::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if has_command {
|
||||||
|
McpTransportType::Stdio
|
||||||
|
} else {
|
||||||
|
McpTransportType::Http
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match transport {
|
||||||
|
McpTransportType::Stdio => build_stdio(cli, has_url),
|
||||||
|
McpTransportType::Http | McpTransportType::Sse => build_remote(cli, transport, has_command),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_stdio(cli: &Cli, has_url: bool) -> Result<McpServer> {
|
||||||
|
if cli.mcp_command.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"stdio MCP server requires a command. Pass it after `--`, e.g. \
|
||||||
|
`--mcp-add NAME -- npx some-server --flag`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if has_url {
|
||||||
|
bail!("stdio MCP server does not accept --url");
|
||||||
|
}
|
||||||
|
if !cli.header.is_empty() {
|
||||||
|
bail!("stdio MCP server does not accept --header");
|
||||||
|
}
|
||||||
|
if cli.client_id.is_some()
|
||||||
|
|| cli.client_secret.is_some()
|
||||||
|
|| cli.callback_port.is_some()
|
||||||
|
|| cli.redirect_host.is_some()
|
||||||
|
{
|
||||||
|
bail!("stdio MCP server does not accept OAuth flags");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cmd, args) = cli.mcp_command.split_first().unwrap();
|
||||||
|
|
||||||
|
let mut env: IndexMap<String, JsonField> = IndexMap::new();
|
||||||
|
for kv in &cli.env {
|
||||||
|
let (k, v) = kv
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| anyhow!("invalid --env value '{kv}': expected KEY=VALUE"))?;
|
||||||
|
if k.is_empty() {
|
||||||
|
bail!("invalid --env value '{kv}': KEY cannot be empty");
|
||||||
|
}
|
||||||
|
env.insert(k.to_string(), JsonField::Str(v.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(McpServer {
|
||||||
|
transport_type: McpTransportType::Stdio,
|
||||||
|
command: Some(cmd.clone()),
|
||||||
|
args: (!args.is_empty()).then(|| args.to_vec()),
|
||||||
|
env: (!env.is_empty()).then_some(env),
|
||||||
|
cwd: cli.cwd.clone(),
|
||||||
|
url: None,
|
||||||
|
headers: None,
|
||||||
|
oauth: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_remote(cli: &Cli, transport: McpTransportType, has_command: bool) -> Result<McpServer> {
|
||||||
|
if has_command {
|
||||||
|
bail!(
|
||||||
|
"http/sse MCP server does not accept a trailing `-- <cmd>`. Use `--url` \
|
||||||
|
to specify the endpoint."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let url = cli
|
||||||
|
.url
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("http/sse MCP server requires --url <URL>"))?;
|
||||||
|
if !cli.env.is_empty() {
|
||||||
|
bail!("http/sse MCP server does not accept --env; use --header instead");
|
||||||
|
}
|
||||||
|
if cli.cwd.is_some() {
|
||||||
|
bail!("http/sse MCP server does not accept --cwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers: IndexMap<String, String> = IndexMap::new();
|
||||||
|
for h in &cli.header {
|
||||||
|
let (name, value) = h
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| anyhow!("invalid --header value '{h}': expected 'Name: Value'"))?;
|
||||||
|
let name = name.trim();
|
||||||
|
let value = value.trim_start_matches(' ');
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("invalid --header value '{h}': header name cannot be empty");
|
||||||
|
}
|
||||||
|
headers.insert(name.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauth = if cli.client_id.is_some()
|
||||||
|
|| cli.client_secret.is_some()
|
||||||
|
|| cli.callback_port.is_some()
|
||||||
|
|| cli.redirect_host.is_some()
|
||||||
|
{
|
||||||
|
Some(McpOAuthConfig {
|
||||||
|
client_id: cli.client_id.clone(),
|
||||||
|
client_secret: cli.client_secret.clone(),
|
||||||
|
callback_port: cli.callback_port,
|
||||||
|
redirect_host: cli.redirect_host.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(McpServer {
|
||||||
|
transport_type: transport,
|
||||||
|
command: None,
|
||||||
|
args: None,
|
||||||
|
env: None,
|
||||||
|
cwd: None,
|
||||||
|
url: Some(url),
|
||||||
|
headers: (!headers.is_empty()).then_some(headers),
|
||||||
|
oauth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provision_secrets(cli: &Cli, vault: &Vault) -> Result<()> {
|
||||||
|
let mut sources: Vec<&str> = Vec::new();
|
||||||
|
if let Some(s) = cli.url.as_deref() {
|
||||||
|
sources.push(s);
|
||||||
|
}
|
||||||
|
if let Some(s) = cli.client_secret.as_deref() {
|
||||||
|
sources.push(s);
|
||||||
|
}
|
||||||
|
if let Some(s) = cli.client_id.as_deref() {
|
||||||
|
sources.push(s);
|
||||||
|
}
|
||||||
|
if let Some(s) = cli.redirect_host.as_deref() {
|
||||||
|
sources.push(s);
|
||||||
|
}
|
||||||
|
if let Some(s) = cli.cwd.as_deref() {
|
||||||
|
sources.push(s);
|
||||||
|
}
|
||||||
|
sources.extend(cli.env.iter().map(String::as_str));
|
||||||
|
sources.extend(cli.header.iter().map(String::as_str));
|
||||||
|
|
||||||
|
let mut needed: IndexSet<String> = IndexSet::new();
|
||||||
|
for value in sources {
|
||||||
|
for caps in SECRET_RE.captures_iter(value).filter_map(Result::ok) {
|
||||||
|
if let Some(m) = caps.get(1) {
|
||||||
|
needed.insert(m.as_str().trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: HashSet<String> = vault.list_secrets(false)?.into_iter().collect();
|
||||||
|
for name in needed {
|
||||||
|
if existing.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eprintln!("Value references vault secret {{{{ {name} }}}} which is not stored yet.");
|
||||||
|
let ok = Confirm::new(&format!("Add '{name}' to the vault now?"))
|
||||||
|
.with_default(true)
|
||||||
|
.prompt()?;
|
||||||
|
if !ok {
|
||||||
|
bail!(
|
||||||
|
"Vault secret '{name}' is required by the config; aborting. \
|
||||||
|
Add it later with `coyote --add-secret {name}`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
vault.add_secret(&name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_for_scope_or_search(
|
||||||
|
name: &str,
|
||||||
|
scope: Option<McpScopeArg>,
|
||||||
|
) -> Result<(PathBuf, McpServersConfig)> {
|
||||||
|
if let Some(s) = scope {
|
||||||
|
let path = match s {
|
||||||
|
McpScopeArg::User => paths::mcp_config_file(),
|
||||||
|
McpScopeArg::Workspace => paths::workspace_mcp_config_file()
|
||||||
|
.ok_or_else(|| anyhow!("no workspace mcp.json found in the current directory"))?,
|
||||||
|
};
|
||||||
|
let cfg = load_config_raw(&path)?;
|
||||||
|
if !cfg.mcp_servers.contains_key(name) {
|
||||||
|
bail!(
|
||||||
|
"MCP server '{name}' not found in {} scope ({})",
|
||||||
|
scope_label(s),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((path, cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_path = paths::mcp_config_file();
|
||||||
|
let user_cfg = load_config_raw(&user_path)?;
|
||||||
|
if user_cfg.mcp_servers.contains_key(name) {
|
||||||
|
return Ok((user_path, user_cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ws_path) = paths::workspace_mcp_config_file() {
|
||||||
|
let ws_cfg = load_config_raw(&ws_path)?;
|
||||||
|
if ws_cfg.mcp_servers.contains_key(name) {
|
||||||
|
return Ok((ws_path, ws_cfg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("MCP server '{name}' not found in any scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_path_for_scope(scope: McpScopeArg) -> PathBuf {
|
||||||
|
match scope {
|
||||||
|
McpScopeArg::User => paths::mcp_config_file(),
|
||||||
|
McpScopeArg::Workspace => paths::workspace_mcp_config_file()
|
||||||
|
.unwrap_or_else(|| paths::workspace_config_dir().join("mcp.json")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_label(scope: McpScopeArg) -> &'static str {
|
||||||
|
match scope {
|
||||||
|
McpScopeArg::User => "user",
|
||||||
|
McpScopeArg::Workspace => "workspace",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config_raw(path: &Path) -> Result<McpServersConfig> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(McpServersConfig {
|
||||||
|
mcp_servers: IndexMap::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("failed to read MCP config at {}", path.display()))?;
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Ok(McpServersConfig {
|
||||||
|
mcp_servers: IndexMap::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&raw)
|
||||||
|
.with_context(|| format!("failed to parse MCP config at {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_config(path: &Path, config: &McpServersConfig) -> Result<()> {
|
||||||
|
ensure_parent_exists(path)?;
|
||||||
|
let serialized =
|
||||||
|
serde_json::to_string_pretty(config).context("failed to serialize MCP config")?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, &serialized)
|
||||||
|
.with_context(|| format!("failed to write temporary MCP config at {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path)
|
||||||
|
.with_context(|| format!("failed to finalize MCP config at {}", path.display()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub(crate) mod manage;
|
||||||
pub(crate) mod oauth;
|
pub(crate) mod oauth;
|
||||||
mod sse_transport;
|
mod sse_transport;
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ pub(crate) struct McpServersConfig {
|
|||||||
pub(crate) struct McpOAuthConfig {
|
pub(crate) struct McpOAuthConfig {
|
||||||
#[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
|
||||||
pub client_id: Option<String>,
|
pub client_id: Option<String>,
|
||||||
|
#[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client_secret: Option<String>,
|
||||||
#[serde(rename = "callbackPort", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "callbackPort", skip_serializing_if = "Option::is_none")]
|
||||||
pub callback_port: Option<u16>,
|
pub callback_port: Option<u16>,
|
||||||
#[serde(rename = "redirectHost", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "redirectHost", skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
Reference in New Issue
Block a user