Skip to main content

vtcode_core/mcp/
cli.rs

1//! CLI commands for managing Model Context Protocol providers.
2
3use crate::cli::input_hardening::validate_agent_safe_text;
4use crate::config::VTCodeConfig;
5use crate::config::loader::ConfigManager;
6use crate::config::mcp::{
7    McpHttpHandshakeMode, McpHttpServerConfig, McpProviderConfig, McpStdioServerConfig, McpTransportConfig,
8};
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{ArgGroup, Args, Subcommand};
11use hashbrown::HashMap;
12use serde_json::json;
13use std::path::{Path, PathBuf};
14use std::sync::{LazyLock, Mutex};
15use vtcode_commons::VtCodePaths;
16use vtcode_config::auth::{
17    AuthCallbackOutcome, McpOAuthConfig, McpOAuthService, OAuthCallbackPage, start_auth_code_callback_server,
18};
19
20static GLOBAL_CONFIG_PATH_OVERRIDE: LazyLock<Mutex<Option<PathBuf>>> = LazyLock::new(|| Mutex::new(None));
21
22/// Subcommands exposed by the `vtcode mcp` entrypoint.
23#[derive(Debug, Clone, Subcommand)]
24pub enum McpCommands {
25    /// List configured MCP providers.
26    List(ListArgs),
27
28    /// Show details for a single MCP provider.
29    Get(GetArgs),
30
31    /// Add or update an MCP provider definition.
32    Add(AddArgs),
33
34    /// Remove an MCP provider definition.
35    Remove(RemoveArgs),
36
37    /// Start OAuth login for an HTTP MCP provider.
38    Login(LoginArgs),
39
40    /// Clear stored OAuth credentials for an HTTP MCP provider.
41    Logout(LogoutArgs),
42}
43
44/// Arguments for the `list` subcommand.
45#[derive(Debug, Clone, Args)]
46pub struct ListArgs {
47    /// Output the configured providers as JSON.
48    #[arg(long)]
49    pub json: bool,
50}
51
52/// Arguments for the `get` subcommand.
53#[derive(Debug, Clone, Args)]
54pub struct GetArgs {
55    /// Name of the provider to display.
56    pub name: String,
57
58    /// Output the provider configuration as JSON.
59    #[arg(long)]
60    pub json: bool,
61}
62
63/// Arguments for the `add` subcommand.
64#[derive(Debug, Clone, Args)]
65pub struct AddArgs {
66    /// Name for the provider configuration.
67    pub name: String,
68
69    #[command(flatten)]
70    pub transport_args: AddMcpTransportArgs,
71
72    /// Maximum concurrent requests handled by the provider.
73    #[arg(long)]
74    pub max_concurrent_requests: Option<usize>,
75
76    /// Persist the provider in a disabled state.
77    #[arg(long)]
78    pub disabled: bool,
79}
80
81/// Mutually exclusive transport arguments for MCP providers.
82#[derive(Debug, Clone, Args)]
83#[command(
84    group(
85        ArgGroup::new("transport")
86            .args(["command", "url"])
87            .required(true)
88            .multiple(false)
89    )
90)]
91pub struct AddMcpTransportArgs {
92    #[command(flatten)]
93    pub stdio: Option<AddMcpStdioArgs>,
94
95    #[command(flatten)]
96    pub streamable_http: Option<AddMcpStreamableHttpArgs>,
97}
98
99/// stdio transport arguments for MCP providers.
100#[derive(Debug, Clone, Args)]
101pub struct AddMcpStdioArgs {
102    /// Command to launch the MCP server. Use `--url` for HTTP servers.
103    #[arg(long = "command", num_args = 1.., allow_hyphen_values = true, value_name = "COMMAND")]
104    pub command: Vec<String>,
105
106    /// Environment variables to export when launching the server.
107    #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
108    pub env: Vec<(String, String)>,
109
110    /// Optional working directory for the command.
111    #[arg(long, value_name = "PATH")]
112    pub working_directory: Option<String>,
113}
114
115/// Streamable HTTP transport arguments for MCP providers.
116#[derive(Debug, Clone, Args)]
117pub struct AddMcpStreamableHttpArgs {
118    /// URL for the streamable HTTP MCP server.
119    #[arg(long)]
120    pub url: String,
121
122    /// Optional environment variable containing the bearer token.
123    #[arg(long = "bearer-token-env-var", value_name = "ENV_VAR", requires = "url")]
124    pub bearer_token_env_var: Option<String>,
125
126    /// Additional headers to send with each request (KEY=VALUE form).
127    #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
128    pub header: Vec<(String, String)>,
129
130    /// Headers whose values are sourced from environment variables (KEY=ENV_VAR form).
131    #[arg(long, value_parser = parse_env_pair, value_name = "KEY=ENV_VAR")]
132    pub env_header: Vec<(String, String)>,
133}
134
135/// Arguments for the `remove` subcommand.
136#[derive(Debug, Clone, Args)]
137pub struct RemoveArgs {
138    /// Name of the provider to remove.
139    pub name: String,
140}
141
142/// Arguments for the `login` subcommand.
143#[derive(Debug, Clone, Args)]
144pub struct LoginArgs {
145    /// Name of the provider to authenticate.
146    pub name: String,
147}
148
149/// Arguments for the `logout` subcommand.
150#[derive(Debug, Clone, Args)]
151pub struct LogoutArgs {
152    /// Name of the provider to deauthenticate.
153    pub name: String,
154}
155
156/// Entry point for the `vtcode mcp` command group.
157pub async fn handle_mcp_command(command: McpCommands) -> Result<()> {
158    match command {
159        McpCommands::List(args) => run_list(args).await,
160        McpCommands::Get(args) => run_get(args).await,
161        McpCommands::Add(args) => run_add(args).await,
162        McpCommands::Remove(args) => run_remove(args).await,
163        McpCommands::Login(args) => run_login(args).await,
164        McpCommands::Logout(args) => run_logout(args).await,
165    }
166}
167
168async fn run_add(add_args: AddArgs) -> Result<()> {
169    validate_provider_name(&add_args.name)?;
170
171    let (mut config, path) = load_global_config()?;
172
173    let AddArgs {
174        name,
175        transport_args,
176        max_concurrent_requests,
177        disabled,
178    } = add_args;
179
180    let transport = match transport_args.clone() {
181        AddMcpTransportArgs { stdio: Some(stdio), .. } => build_stdio_transport(stdio)?,
182        AddMcpTransportArgs { streamable_http: Some(http), .. } => build_http_transport(http)?,
183        _ => bail!("either --command or --url must be provided"),
184    };
185
186    let mut provider = McpProviderConfig::default();
187    provider.name = name.clone();
188    provider.transport = transport;
189    provider.enabled = !disabled;
190    provider.max_concurrent_requests = max_concurrent_requests.unwrap_or(provider.max_concurrent_requests);
191
192    if let Some(stdio) = transport_args.stdio {
193        provider.env = stdio.env.into_iter().collect();
194    }
195
196    let was_new = upsert_provider(&mut config, provider);
197    write_global_config(&path, &config).await?;
198
199    if was_new {
200        println!("Added MCP provider '{name}'.");
201    } else {
202        println!("Updated MCP provider '{name}'.");
203    }
204
205    Ok(())
206}
207
208async fn run_remove(remove_args: RemoveArgs) -> Result<()> {
209    validate_provider_name(&remove_args.name)?;
210
211    let (mut config, path) = load_global_config()?;
212    let original_len = config.mcp.providers.len();
213    config.mcp.providers.retain(|provider| provider.name != remove_args.name);
214
215    if config.mcp.providers.len() == original_len {
216        println!("No MCP provider named '{}' found.", remove_args.name);
217        return Ok(());
218    }
219
220    write_global_config(&path, &config).await?;
221    println!("Removed MCP provider '{}'.", remove_args.name);
222    Ok(())
223}
224
225async fn run_list(list_args: ListArgs) -> Result<()> {
226    let (config, _) = load_global_config()?;
227    let mut providers = config.mcp.providers.clone();
228    providers.sort_by(|a, b| a.name.cmp(&b.name));
229
230    if list_args.json {
231        let payload: Vec<_> = providers.into_iter().map(|provider| json_provider(&provider)).collect();
232        let output = serde_json::to_string_pretty(&payload).context("failed to serialize MCP providers to JSON")?;
233        println!("{output}");
234        return Ok(());
235    }
236
237    if providers.is_empty() {
238        println!("No MCP providers configured. Use `vtcode mcp add <name> --command <binary>` to register one.");
239        return Ok(());
240    }
241
242    let mut stdio_rows: Vec<[String; 6]> = Vec::new();
243    let mut http_rows: Vec<[String; 6]> = Vec::new();
244
245    for provider in &providers {
246        match &provider.transport {
247            McpTransportConfig::Stdio(stdio) => {
248                let args_display = if stdio.args.is_empty() {
249                    "-".to_owned()
250                } else {
251                    stdio.args.join(" ")
252                };
253                let env_display = if provider.env.is_empty() {
254                    "-".to_owned()
255                } else {
256                    format_env_map(&provider.env)
257                };
258                let working_dir = stdio.working_directory.as_deref().unwrap_or("-").to_owned();
259                let status = if provider.enabled { "enabled" } else { "disabled" };
260                stdio_rows.push([
261                    provider.name.clone(),
262                    stdio.command.clone(),
263                    args_display,
264                    env_display,
265                    working_dir,
266                    format!("{status} (max {max_requests})", max_requests = provider.max_concurrent_requests),
267                ]);
268            }
269            McpTransportConfig::Http(http) => {
270                let status = if provider.enabled { "enabled" } else { "disabled" };
271                let protocol = http.protocol_version.clone();
272                http_rows.push([
273                    provider.name.clone(),
274                    http.endpoint.clone(),
275                    http_auth_label(http),
276                    protocol,
277                    http_oauth_status_label(&provider.name, http),
278                    format!("{status} (max {max_requests})", max_requests = provider.max_concurrent_requests),
279                ]);
280            }
281        }
282    }
283
284    if !stdio_rows.is_empty() {
285        print_stdio_table(&stdio_rows);
286    }
287
288    if !stdio_rows.is_empty() && !http_rows.is_empty() {
289        println!();
290    }
291
292    if !http_rows.is_empty() {
293        print_http_table(&http_rows);
294    }
295
296    Ok(())
297}
298
299async fn run_get(get_args: GetArgs) -> Result<()> {
300    let (config, _) = load_global_config()?;
301    let provider = config
302        .mcp
303        .providers
304        .iter()
305        .find(|provider| provider.name == get_args.name)
306        .ok_or_else(|| anyhow!("No MCP provider named '{}' found.", get_args.name))?;
307
308    if get_args.json {
309        let output = serde_json::to_string_pretty(&json_provider(provider))
310            .context("failed to serialize MCP provider to JSON")?;
311        println!("{output}");
312        return Ok(());
313    }
314
315    println!("{}", provider.name);
316    println!("  enabled: {}", provider.enabled);
317    println!("  max_concurrent_requests: {}", provider.max_concurrent_requests);
318    if !provider.env.is_empty() {
319        println!("  env: {}", format_env_map(&provider.env));
320    }
321
322    match &provider.transport {
323        McpTransportConfig::Stdio(stdio) => {
324            println!("  transport: stdio");
325            println!("  command: {}", stdio.command);
326            let args_display = if stdio.args.is_empty() {
327                "-".to_owned()
328            } else {
329                stdio.args.join(" ")
330            };
331            println!("  args: {args_display}");
332            let working_directory = stdio.working_directory.as_deref().unwrap_or("-");
333            println!("  working_directory: {working_directory}");
334        }
335        McpTransportConfig::Http(http) => {
336            println!("  transport: http");
337            println!("  endpoint: {}", http.endpoint);
338            println!("  auth: {}", http_auth_label(http));
339            println!("  oauth_status: {}", http_oauth_status_label(&provider.name, http));
340            println!("  protocol_version: {}", http.protocol_version);
341            println!(
342                "  handshake: {}",
343                match http.handshake {
344                    McpHttpHandshakeMode::Legacy => "legacy",
345                    McpHttpHandshakeMode::Auto => "auto",
346                }
347            );
348            if !http.http_headers.is_empty() {
349                println!("  headers: {}", format_env_map(&http.http_headers));
350            }
351            if !http.env_http_headers.is_empty() {
352                println!("  env_headers: {}", format_env_map(&http.env_http_headers));
353            }
354            if let Some(oauth) = &http.oauth {
355                println!("  oauth.authorization_url: {}", oauth.authorization_url);
356                println!("  oauth.token_url: {}", oauth.token_url);
357                println!("  oauth.client_id: {}", oauth.client_id);
358                if !oauth.scopes.is_empty() {
359                    println!("  oauth.scopes: {}", oauth.scopes.join(", "));
360                }
361                println!("  oauth.callback_port: {}", oauth.callback_port);
362            }
363        }
364    }
365
366    println!("  remove: vtcode mcp remove {}", provider.name);
367
368    Ok(())
369}
370
371async fn run_login(login_args: LoginArgs) -> Result<()> {
372    validate_provider_name(&login_args.name)?;
373
374    let (config, _) = load_global_config()?;
375    let provider = config
376        .mcp
377        .providers
378        .iter()
379        .find(|provider| provider.name == login_args.name)
380        .ok_or_else(|| anyhow!("No MCP provider named '{}' found.", login_args.name))?;
381    let oauth = provider_http_oauth_config(provider)?;
382    let service = McpOAuthService::new();
383    let prepared = service.prepare_login(&provider.name, oauth)?;
384    let callback_server = start_auth_code_callback_server(
385        prepared.callback_port,
386        prepared.timeout_secs,
387        OAuthCallbackPage::custom(
388            "mcp",
389            "The MCP provider is now connected.",
390            "Unable to connect this MCP provider.",
391            "You can try again anytime using `vtcode mcp login <name>`.",
392        ),
393        Some(prepared.expected_state().to_string()),
394    )
395    .await?;
396
397    println!("Starting MCP OAuth login for '{}'...", provider.name);
398    open_browser_or_print_url(&prepared.auth_url)?;
399    println!("Waiting for the OAuth callback on localhost:{}...", prepared.callback_port);
400
401    let completion = match callback_server.wait().await? {
402        AuthCallbackOutcome::Code(code) => service.complete_login(&provider.name, oauth, &prepared, &code).await?,
403        AuthCallbackOutcome::Cancelled => {
404            bail!("OAuth flow was cancelled")
405        }
406        AuthCallbackOutcome::Error(error) => {
407            bail!(error)
408        }
409    };
410
411    println!("MCP OAuth login complete for '{}'.", completion.name);
412    Ok(())
413}
414
415async fn run_logout(logout_args: LogoutArgs) -> Result<()> {
416    validate_provider_name(&logout_args.name)?;
417
418    let (config, _) = load_global_config()?;
419    let provider = config
420        .mcp
421        .providers
422        .iter()
423        .find(|provider| provider.name == logout_args.name)
424        .ok_or_else(|| anyhow!("No MCP provider named '{}' found.", logout_args.name))?;
425    let oauth = provider_http_oauth_config(provider)?;
426    let service = McpOAuthService::new();
427    service.logout(&provider.name, oauth.credentials_store_mode)?;
428    println!("Cleared MCP OAuth credentials for '{}'.", provider.name);
429    Ok(())
430}
431
432fn build_stdio_transport(args: AddMcpStdioArgs) -> Result<McpTransportConfig> {
433    let mut command_parts = args.command.into_iter();
434    let command_bin = command_parts
435        .next()
436        .ok_or_else(|| anyhow!("command is required when using stdio transport"))?;
437    validate_agent_safe_text("command", &command_bin)?;
438    let command_args: Vec<String> = command_parts.collect();
439    for arg in &command_args {
440        validate_agent_safe_text("command argument", arg)?;
441    }
442    if let Some(working_directory) = args.working_directory.as_deref() {
443        validate_agent_safe_text("working_directory", working_directory)?;
444    }
445
446    let transport = McpStdioServerConfig {
447        command: command_bin,
448        args: command_args,
449        working_directory: args.working_directory,
450    };
451
452    Ok(McpTransportConfig::Stdio(transport))
453}
454
455fn build_http_transport(args: AddMcpStreamableHttpArgs) -> Result<McpTransportConfig> {
456    validate_agent_safe_text("url", &args.url)?;
457    if let Some(env_var) = args.bearer_token_env_var.as_deref() {
458        validate_agent_safe_text("bearer_token_env_var", env_var)?;
459    }
460    let headers = args.header.into_iter().collect::<HashMap<_, _>>();
461    let env_headers = args.env_header.into_iter().collect::<HashMap<_, _>>();
462    let default_config = McpHttpServerConfig::default();
463    let transport = McpHttpServerConfig {
464        endpoint: args.url,
465        api_key_env: args.bearer_token_env_var,
466        oauth: None,
467        protocol_version: default_config.protocol_version,
468        handshake: default_config.handshake,
469        http_headers: headers,
470        env_http_headers: env_headers,
471    };
472
473    Ok(McpTransportConfig::Http(transport))
474}
475
476fn upsert_provider(config: &mut VTCodeConfig, provider: McpProviderConfig) -> bool {
477    if let Some(existing) = config.mcp.providers.iter_mut().find(|entry| entry.name == provider.name) {
478        *existing = provider;
479        false
480    } else {
481        config.mcp.providers.push(provider);
482        true
483    }
484}
485
486fn json_provider(provider: &McpProviderConfig) -> serde_json::Value {
487    let transport = match &provider.transport {
488        McpTransportConfig::Stdio(stdio) => json!({
489            "type": "stdio",
490            "command": stdio.command,
491            "args": stdio.args,
492            "working_directory": stdio.working_directory,
493            "env": provider.env,
494        }),
495        McpTransportConfig::Http(http) => json!({
496            "type": "http",
497            "endpoint": http.endpoint,
498            "api_key_env": http.api_key_env,
499            "oauth": http.oauth,
500            "protocol_version": http.protocol_version,
501            "handshake": http.handshake,
502            "headers": http.http_headers,
503            "env_headers": http.env_http_headers,
504        }),
505    };
506
507    json!({
508        "name": provider.name,
509        "enabled": provider.enabled,
510        "transport": transport,
511        "max_concurrent_requests": provider.max_concurrent_requests,
512    })
513}
514
515fn provider_http_oauth_config(provider: &McpProviderConfig) -> Result<&McpOAuthConfig> {
516    match &provider.transport {
517        McpTransportConfig::Http(http) => http
518            .oauth
519            .as_ref()
520            .ok_or_else(|| anyhow!("MCP provider '{}' does not have HTTP OAuth configured.", provider.name)),
521        McpTransportConfig::Stdio(_) => Err(anyhow!(
522            "MCP provider '{}' uses stdio transport and does not support HTTP OAuth login.",
523            provider.name
524        )),
525    }
526}
527
528fn http_auth_label(http: &McpHttpServerConfig) -> String {
529    if http.oauth.is_some() {
530        "oauth".to_string()
531    } else {
532        http.api_key_env
533            .clone()
534            .map(|env| format!("env:{env}"))
535            .unwrap_or_else(|| "none".to_string())
536    }
537}
538
539fn http_oauth_status_label(provider_name: &str, http: &McpHttpServerConfig) -> String {
540    let Some(oauth) = http.oauth.as_ref() else {
541        return "-".to_string();
542    };
543
544    match McpOAuthService::new().status(provider_name, oauth.credentials_store_mode) {
545        Ok(vtcode_config::auth::McpOAuthStatus::Authenticated { .. }) => "authenticated".to_string(),
546        Ok(vtcode_config::auth::McpOAuthStatus::NotAuthenticated) => "not authenticated".to_string(),
547        Err(error) => format!("error: {error}"),
548    }
549}
550
551fn open_browser_or_print_url(url: &str) -> Result<()> {
552    println!("Open this URL to continue OAuth:\n{url}");
553    if let Err(error) = webbrowser::open(url) {
554        println!("Automatic browser open failed: {error}");
555    }
556    Ok(())
557}
558
559fn format_env_map(map: &HashMap<String, String>) -> String {
560    let mut entries: Vec<_> = map.iter().collect();
561    entries.sort_by_key(|(a, _)| *a);
562    entries
563        .into_iter()
564        .map(|(k, v)| format!("{k}={v}"))
565        .collect::<Vec<_>>()
566        .join(", ")
567}
568
569fn load_global_config() -> Result<(VTCodeConfig, PathBuf)> {
570    let path = global_config_path()?;
571    let manager = ConfigManager::load_global()
572        .with_context(|| format!("failed to load global configuration before writing {}", path.display()))?;
573    Ok((manager.config().clone(), path))
574}
575
576async fn write_global_config(path: &Path, config: &VTCodeConfig) -> Result<()> {
577    if let Some(parent) = path.parent() {
578        VtCodePaths::ensure_user_dir(parent)
579            .with_context(|| format!("failed to create directory {}", parent.display()))?;
580    }
581
582    let contents = toml::to_string_pretty(config).context("failed to serialize configuration")?;
583    VtCodePaths::write_private_file_atomic(path, contents.as_bytes())
584        .with_context(|| format!("failed to write configuration to {}", path.display()))?;
585    Ok(())
586}
587
588fn global_config_path() -> Result<PathBuf> {
589    if let Some(path) = GLOBAL_CONFIG_PATH_OVERRIDE
590        .lock()
591        .map_err(|_e| anyhow!("global config path override mutex poisoned"))?
592        .clone()
593    {
594        return Ok(path);
595    }
596
597    VtCodePaths::resolve()
598        .map(|paths| paths.config_file())
599        .context("failed to resolve canonical VT Code configuration path")
600}
601
602#[doc(hidden)]
603pub fn set_global_config_path_override_for_tests(path: Option<PathBuf>) -> Result<()> {
604    let mut guard = GLOBAL_CONFIG_PATH_OVERRIDE
605        .lock()
606        .map_err(|_e| anyhow!("global config path override mutex poisoned"))?;
607    *guard = path;
608    Ok(())
609}
610
611fn parse_env_pair(raw: &str) -> Result<(String, String), String> {
612    let mut parts = raw.splitn(2, '=');
613    let key = parts
614        .next()
615        .map(str::trim)
616        .filter(|s| !s.is_empty())
617        .ok_or_else(|| "entries must be in KEY=VALUE form".to_owned())?;
618    let value = parts
619        .next()
620        .map(str::to_owned)
621        .ok_or_else(|| "entries must be in KEY=VALUE form".to_owned())?;
622    validate_agent_safe_text("env key", key).map_err(|err| err.to_string())?;
623    validate_agent_safe_text("env value", &value).map_err(|err| err.to_string())?;
624    Ok((key.to_owned(), value))
625}
626
627fn validate_provider_name(name: &str) -> Result<()> {
628    validate_agent_safe_text("provider name", name)?;
629    let is_valid = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
630
631    if is_valid {
632        Ok(())
633    } else {
634        bail!("invalid provider name '{name}' (use letters, numbers, '-', '_')");
635    }
636}
637
638fn print_stdio_table(rows: &[[String; 6]]) {
639    let mut widths = [
640        "Name".len(),
641        "Command".len(),
642        "Args".len(),
643        "Env".len(),
644        "Working Dir".len(),
645        "Status".len(),
646    ];
647
648    for row in rows {
649        for (width, cell) in widths.iter_mut().zip(row.iter()) {
650            *width = (*width).max(cell.len());
651        }
652    }
653
654    let [name_w, command_w, args_w, env_w, workdir_w, status_w] = widths;
655
656    println!(
657        "{name:<name_w$}  {command:<command_w$}  {args:<args_w$}  {env:<env_w$}  {workdir:<workdir_w$}  {status:<status_w$}",
658        name = "Name",
659        command = "Command",
660        args = "Args",
661        env = "Env",
662        workdir = "Working Dir",
663        status = "Status",
664    );
665
666    for row in rows {
667        println!(
668            "{name:<name_w$}  {command:<command_w$}  {args:<args_w$}  {env:<env_w$}  {workdir:<workdir_w$}  {status:<status_w$}",
669            name = row[0],
670            command = row[1],
671            args = row[2],
672            env = row[3],
673            workdir = row[4],
674            status = row[5],
675        );
676    }
677}
678
679fn print_http_table(rows: &[[String; 6]]) {
680    let mut widths = [
681        "Name".len(),
682        "Endpoint".len(),
683        "Auth".len(),
684        "Protocol".len(),
685        "OAuth Status".len(),
686        "Status".len(),
687    ];
688
689    for row in rows {
690        for (width, cell) in widths.iter_mut().zip(row.iter()) {
691            *width = (*width).max(cell.len());
692        }
693    }
694
695    let [name_w, endpoint_w, auth_w, protocol_w, oauth_w, status_w] = widths;
696
697    println!(
698        "{name:<name_w$}  {endpoint:<endpoint_w$}  {auth:<auth_w$}  {protocol:<protocol_w$}  {oauth:<oauth_w$}  {status:<status_w$}",
699        name = "Name",
700        endpoint = "Endpoint",
701        auth = "Auth",
702        protocol = "Protocol",
703        oauth = "OAuth Status",
704        status = "Status",
705    );
706
707    for row in rows {
708        println!(
709            "{name:<name_w$}  {endpoint:<endpoint_w$}  {auth:<auth_w$}  {protocol:<protocol_w$}  {oauth:<oauth_w$}  {status:<status_w$}",
710            name = row[0],
711            endpoint = row[1],
712            auth = row[2],
713            protocol = row[3],
714            oauth = row[4],
715            status = row[5],
716        );
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::{
723        AddArgs, AddMcpStdioArgs, AddMcpTransportArgs, GLOBAL_CONFIG_PATH_OVERRIDE, parse_env_pair,
724        validate_provider_name,
725    };
726    use clap::{Args, Command, FromArgMatches};
727    use std::path::PathBuf;
728
729    #[test]
730    fn add_args_accepts_documented_command_flag() {
731        let command = AddArgs::augment_args(Command::new("add"));
732        let matches = command
733            .try_get_matches_from([
734                "add",
735                "test-provider",
736                "--command",
737                "uvx",
738                "mcp-server-time",
739                "--local-timezone=Asia/Ho_Chi_Minh",
740            ])
741            .expect("documented --command form should parse");
742        let args = AddArgs::from_arg_matches(&matches).expect("parsed matches should map to AddArgs");
743
744        let AddMcpTransportArgs { stdio: Some(stdio), streamable_http: None } = args.transport_args else {
745            panic!("stdio transport should be selected");
746        };
747        let AddMcpStdioArgs { command, env, working_directory } = stdio;
748        assert_eq!(command, ["uvx", "mcp-server-time", "--local-timezone=Asia/Ho_Chi_Minh"]);
749        assert!(env.is_empty());
750        assert_eq!(working_directory, None);
751    }
752
753    #[test]
754    fn parse_env_pair_accepts_valid_input() {
755        let parsed = parse_env_pair("FOO=bar").expect("valid env pair");
756        assert_eq!(parsed.0, "FOO");
757        assert_eq!(parsed.1, "bar");
758    }
759
760    #[test]
761    fn parse_env_pair_rejects_control_chars() {
762        let err = parse_env_pair("FOO=bad\u{0000}value").expect_err("nul must be rejected");
763        assert!(err.contains("U+0000"));
764    }
765
766    #[test]
767    fn validate_provider_name_rejects_control_chars() {
768        let err = validate_provider_name("bad\u{0007}name").expect_err("control chars rejected");
769        assert!(err.to_string().contains("U+0007"));
770    }
771
772    #[test]
773    fn global_config_path_uses_test_override() {
774        let override_path = PathBuf::from("/tmp/vtcode-mcp-test.toml");
775        *GLOBAL_CONFIG_PATH_OVERRIDE.lock().expect("override mutex should be available") = Some(override_path.clone());
776        let resolved = super::global_config_path().expect("global config path");
777        assert_eq!(resolved, override_path);
778        *GLOBAL_CONFIG_PATH_OVERRIDE.lock().expect("override mutex should be available") = None;
779    }
780
781    #[test]
782    fn json_provider_reports_http_handshake_mode() {
783        use crate::config::mcp::{McpHttpHandshakeMode, McpHttpServerConfig, McpProviderConfig, McpTransportConfig};
784
785        let provider = McpProviderConfig {
786            name: "modern".to_string(),
787            transport: McpTransportConfig::Http(McpHttpServerConfig {
788                endpoint: "https://example.com/mcp".to_string(),
789                handshake: McpHttpHandshakeMode::Auto,
790                ..McpHttpServerConfig::default()
791            }),
792            ..McpProviderConfig::default()
793        };
794
795        assert_eq!(super::json_provider(&provider)["transport"]["handshake"], "auto");
796    }
797}