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