1use 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#[derive(Debug, Clone, Subcommand)]
24pub enum McpCommands {
25 List(ListArgs),
27
28 Get(GetArgs),
30
31 Add(AddArgs),
33
34 Remove(RemoveArgs),
36
37 Login(LoginArgs),
39
40 Logout(LogoutArgs),
42}
43
44#[derive(Debug, Clone, Args)]
46pub struct ListArgs {
47 #[arg(long)]
49 pub json: bool,
50}
51
52#[derive(Debug, Clone, Args)]
54pub struct GetArgs {
55 pub name: String,
57
58 #[arg(long)]
60 pub json: bool,
61}
62
63#[derive(Debug, Clone, Args)]
65pub struct AddArgs {
66 pub name: String,
68
69 #[command(flatten)]
70 pub transport_args: AddMcpTransportArgs,
71
72 #[arg(long)]
74 pub max_concurrent_requests: Option<usize>,
75
76 #[arg(long)]
78 pub disabled: bool,
79}
80
81#[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#[derive(Debug, Clone, Args)]
101pub struct AddMcpStdioArgs {
102 #[arg(long = "command", num_args = 1.., allow_hyphen_values = true, value_name = "COMMAND")]
104 pub command: Vec<String>,
105
106 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
108 pub env: Vec<(String, String)>,
109
110 #[arg(long, value_name = "PATH")]
112 pub working_directory: Option<String>,
113}
114
115#[derive(Debug, Clone, Args)]
117pub struct AddMcpStreamableHttpArgs {
118 #[arg(long)]
120 pub url: String,
121
122 #[arg(long = "bearer-token-env-var", value_name = "ENV_VAR", requires = "url")]
124 pub bearer_token_env_var: Option<String>,
125
126 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
128 pub header: Vec<(String, String)>,
129
130 #[arg(long, value_parser = parse_env_pair, value_name = "KEY=ENV_VAR")]
132 pub env_header: Vec<(String, String)>,
133}
134
135#[derive(Debug, Clone, Args)]
137pub struct RemoveArgs {
138 pub name: String,
140}
141
142#[derive(Debug, Clone, Args)]
144pub struct LoginArgs {
145 pub name: String,
147}
148
149#[derive(Debug, Clone, Args)]
151pub struct LogoutArgs {
152 pub name: String,
154}
155
156pub 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}