1use 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#[derive(Debug, Clone, Subcommand)]
26pub enum McpCommands {
27 List(ListArgs),
29
30 Get(GetArgs),
32
33 Add(AddArgs),
35
36 Remove(RemoveArgs),
38
39 Login(LoginArgs),
41
42 Logout(LogoutArgs),
44}
45
46#[derive(Debug, Clone, Args)]
48pub struct ListArgs {
49 #[arg(long)]
51 pub json: bool,
52}
53
54#[derive(Debug, Clone, Args)]
56pub struct GetArgs {
57 pub name: String,
59
60 #[arg(long)]
62 pub json: bool,
63}
64
65#[derive(Debug, Clone, Args)]
67pub struct AddArgs {
68 pub name: String,
70
71 #[command(flatten)]
72 pub transport_args: AddMcpTransportArgs,
73
74 #[arg(long)]
76 pub max_concurrent_requests: Option<usize>,
77
78 #[arg(long)]
80 pub disabled: bool,
81}
82
83#[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#[derive(Debug, Clone, Args)]
103pub struct AddMcpStdioArgs {
104 #[arg(trailing_var_arg = true, num_args = 0..)]
106 pub command: Vec<String>,
107
108 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
110 pub env: Vec<(String, String)>,
111
112 #[arg(long, value_name = "PATH")]
114 pub working_directory: Option<String>,
115}
116
117#[derive(Debug, Clone, Args)]
119pub struct AddMcpStreamableHttpArgs {
120 #[arg(long)]
122 pub url: String,
123
124 #[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 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
134 pub header: Vec<(String, String)>,
135
136 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=ENV_VAR")]
138 pub env_header: Vec<(String, String)>,
139}
140
141#[derive(Debug, Clone, Args)]
143pub struct RemoveArgs {
144 pub name: String,
146}
147
148#[derive(Debug, Clone, Args)]
150pub struct LoginArgs {
151 pub name: String,
153}
154
155#[derive(Debug, Clone, Args)]
157pub struct LogoutArgs {
158 pub name: String,
160}
161
162pub 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}