1use std::path::{Path, PathBuf};
7
8use indexmap::IndexMap;
9use reqwest::Url;
10use serde_json::{Map, Value};
11use yapi_types::config::ConfigFile;
12use yapi_types::extension_event::RegisteredMcpServer;
13
14use crate::config::PROJECT_DIR;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum McpExposure {
19 Codemode,
21 Deferred,
23 Direct,
25 Hidden,
27}
28
29impl McpExposure {
30 pub fn as_str(self) -> &'static str {
32 match self {
33 McpExposure::Codemode => "codemode",
34 McpExposure::Deferred => "deferred",
35 McpExposure::Direct => "direct",
36 McpExposure::Hidden => "hidden",
37 }
38 }
39
40 fn parse(value: &Value) -> Option<McpExposure> {
42 match value.as_str()? {
43 "codemode" | "codemode-deferred" => Some(McpExposure::Codemode),
44 "deferred" => Some(McpExposure::Deferred),
45 "direct" => Some(McpExposure::Direct),
46 "hidden" => Some(McpExposure::Hidden),
47 _ => None,
48 }
49 }
50
51 pub fn tool_exposure(self) -> crate::tools::Exposure {
54 use crate::tools::Exposure;
55 match self {
56 McpExposure::Codemode | McpExposure::Deferred => Exposure::Deferred,
57 McpExposure::Direct => Exposure::Direct,
58 McpExposure::Hidden => Exposure::Hidden,
59 }
60 }
61}
62
63#[derive(Clone, Debug, PartialEq)]
65pub enum ServerTransport {
66 Stdio {
68 command: String,
70 args: Vec<String>,
72 env: IndexMap<String, String>,
74 cwd: Option<String>,
76 },
77 Http {
79 url: String,
81 headers: IndexMap<String, String>,
83 auth_provider: Option<String>,
85 oauth: Option<OAuthConfig>,
87 },
88}
89
90#[derive(Clone, Debug, Default, PartialEq)]
93pub struct OAuthConfig {
94 pub client_id: Option<String>,
96 pub client_secret: Option<String>,
98 pub callback_port: Option<u16>,
100 pub callback_url: Option<String>,
102 pub scope: Option<String>,
104 pub client_name: Option<String>,
106 pub auth_server_metadata_url: Option<String>,
108}
109
110impl ServerTransport {
111 pub fn uses_oauth(&self) -> bool {
114 matches!(self, ServerTransport::Http { headers, auth_provider: None, .. }
115 if !headers.keys().any(|name| name.eq_ignore_ascii_case("authorization")))
116 }
117}
118
119#[derive(Clone, Debug, PartialEq)]
121pub struct ServerConfig {
122 pub transport: ServerTransport,
124 pub exposure: Option<McpExposure>,
126 pub description: Option<String>,
128 pub tool_exposure: IndexMap<String, McpExposure>,
130 pub enabled: Option<bool>,
132 pub timeout: Option<f64>,
134}
135
136impl ServerConfig {
137 pub fn exposure(&self) -> McpExposure {
139 self.exposure.unwrap_or(McpExposure::Codemode)
140 }
141
142 pub fn is_enabled(&self) -> bool {
144 self.enabled != Some(false)
145 }
146
147 pub fn tool_exposure(&self, tool: &str) -> McpExposure {
150 if let Some(exposure) = self.tool_exposure.get(tool) {
151 return *exposure;
152 }
153 for (pattern, exposure) in &self.tool_exposure {
154 if pattern.contains('*') && glob(pattern, tool) {
155 return *exposure;
156 }
157 }
158 self.exposure()
159 }
160
161 pub fn exposures(&self) -> Vec<McpExposure> {
163 let mut exposures = vec![self.exposure()];
164 for exposure in self.tool_exposure.values() {
165 if !exposures.contains(exposure) {
166 exposures.push(*exposure);
167 }
168 }
169 exposures
170 }
171}
172
173fn glob(pattern: &str, text: &str) -> bool {
175 let parts: Vec<String> = pattern.split('*').map(regex_lite::escape).collect();
176 regex_lite::Regex::new(&format!("^{}$", parts.join(".*")))
177 .is_ok_and(|regex| regex.is_match(text))
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub enum Scope {
183 Global,
185 Project,
187 Extension,
189}
190
191impl Scope {
192 pub fn as_str(self) -> &'static str {
194 match self {
195 Scope::Global => "global",
196 Scope::Project => "project",
197 Scope::Extension => "extension",
198 }
199 }
200}
201
202#[derive(Clone, Debug, PartialEq)]
204pub struct ServerEntry {
205 pub name: String,
207 pub config: ServerConfig,
209 pub source: PathBuf,
211 pub scope: Scope,
213}
214
215impl ServerEntry {
216 pub fn describe_transport(&self) -> String {
218 match &self.config.transport {
219 ServerTransport::Http { url, .. } => url.clone(),
220 ServerTransport::Stdio { command, args, .. } => std::iter::once(command)
221 .chain(args)
222 .cloned()
223 .collect::<Vec<_>>()
224 .join(" "),
225 }
226 }
227}
228
229#[derive(Clone, Debug, Default, PartialEq)]
231pub struct LoadedConfig {
232 pub servers: Vec<ServerEntry>,
234 pub auto_enable_codemode: Option<bool>,
236 pub errors: Vec<String>,
238}
239
240pub fn namespace(server: &str) -> String {
242 format!("mcp__{}", server.replace('-', "_"))
243}
244
245pub fn check_registration<'a, I>(others: I, name: &str) -> Result<(), String>
249where
250 I: IntoIterator<Item = &'a RegisteredMcpServer>,
251 I::IntoIter: Clone,
252{
253 let mut others = others.into_iter();
254 if let Some(theirs) = others.clone().find(|server| server.name == name) {
255 return Err(format!(
256 "MCP server \"{name}\" is already registered by extension \"{}\"",
257 theirs.extension_path
258 ));
259 }
260 let ours = namespace(name);
261 if let Some(theirs) = others.find(|server| namespace(&server.name) == ours) {
262 return Err(format!(
263 "MCP server \"{name}\" conflicts with registered server \"{}\"",
264 theirs.name
265 ));
266 }
267 Ok(())
268}
269
270const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
271
272fn host(url: &Url) -> String {
274 url.host_str().unwrap_or_default().to_owned()
275}
276
277fn string_map(value: &Value) -> Option<IndexMap<String, String>> {
278 value
279 .as_object()?
280 .iter()
281 .map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned())))
282 .collect()
283}
284
285fn validate_oauth(value: &Value) -> Option<String> {
286 let Some(oauth) = value.as_object() else {
287 return Some("oauth must be an object".into());
288 };
289 let string = |key: &str| oauth.get(key).is_none_or(Value::is_string);
290 if !string("clientId") {
291 return Some("oauth.clientId must be a string".into());
292 }
293 if !string("clientSecret") {
294 return Some("oauth.clientSecret must be a string".into());
295 }
296 let port = oauth.get("callbackPort");
297 if let Some(port) = port
298 && !port
299 .as_f64()
300 .is_some_and(|port| port.fract() == 0.0 && (1.0..=65535.0).contains(&port))
301 {
302 return Some("oauth.callbackPort must be a port number".into());
303 }
304 if let Some(callback) = oauth.get("callbackUrl") {
305 let url = callback.as_str().and_then(|text| Url::parse(text).ok());
306 let loopback = url.as_ref().is_some_and(|url| {
307 url.scheme() == "http"
308 && LOOPBACK_HOSTS.contains(&host(url).as_str())
309 && url.query().is_none()
310 && url.fragment().is_none()
311 });
312 let Some(url) = url.filter(|_| loopback) else {
313 return Some("oauth.callbackUrl must be an http URI on localhost, 127.0.0.1, or [::1] without query or fragment".into());
314 };
315 if let (Some(url_port), Some(port)) = (url.port(), port.and_then(Value::as_f64))
316 && f64::from(url_port) != port
317 {
318 return Some("oauth.callbackUrl and oauth.callbackPort name different ports".into());
319 }
320 }
321 if !string("scope") {
322 return Some("oauth.scope must be a string".into());
323 }
324 if let Some(name) = oauth.get("clientName")
325 && !name.as_str().is_some_and(|name| !name.trim().is_empty())
326 {
327 return Some("oauth.clientName must be a non-empty string".into());
328 }
329 if let Some(metadata) = oauth.get("authServerMetadataUrl") {
330 let url = metadata.as_str().and_then(|text| Url::parse(text).ok());
331 let allowed = url.is_some_and(|url| {
332 url.scheme() == "https"
333 || (url.scheme() == "http" && LOOPBACK_HOSTS.contains(&host(&url).as_str()))
334 });
335 if !allowed {
336 return Some("oauth.authServerMetadataUrl must be an https URL, or http on localhost, 127.0.0.1, or [::1]".into());
337 }
338 }
339 None
340}
341
342pub fn validate_server(name: &str, raw: &Value) -> Result<ServerConfig, String> {
344 let valid_name = !name.is_empty()
345 && name
346 .chars()
347 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
348 if !valid_name {
349 return Err(format!(
350 "invalid server name \"{name}\" (use letters, digits, \"_\" and \"-\")"
351 ));
352 }
353 let Some(value) = raw.as_object() else {
354 return Err(format!("server \"{name}\" must be an object"));
355 };
356 let exposures = "\"codemode\", \"deferred\", \"direct\", \"hidden\"";
357 let exposure = match value.get("exposure") {
358 None => None,
359 Some(raw) => Some(
360 McpExposure::parse(raw)
361 .ok_or_else(|| format!("server \"{name}\": exposure must be one of {exposures}"))?,
362 ),
363 };
364 let mut tool_exposure = IndexMap::new();
365 if let Some(raw) = value.get("toolExposure") {
366 let Some(entries) = raw.as_object() else {
367 return Err(format!(
368 "server \"{name}\": toolExposure must map tool names to exposures"
369 ));
370 };
371 for (tool, entry) in entries {
372 let exposure = McpExposure::parse(entry).ok_or_else(|| {
373 format!("server \"{name}\": toolExposure \"{tool}\" must be one of {exposures}")
374 })?;
375 tool_exposure.insert(tool.clone(), exposure);
376 }
377 }
378 let enabled = match value.get("enabled") {
379 None => None,
380 Some(Value::Bool(enabled)) => Some(*enabled),
381 Some(_) => return Err(format!("server \"{name}\": enabled must be a boolean")),
382 };
383 let description = match value.get("description") {
384 None => None,
385 Some(Value::String(text)) => Some(text.clone()),
386 Some(_) => return Err(format!("server \"{name}\": description must be a string")),
387 };
388 let timeout = match value.get("timeout") {
389 None => None,
390 Some(raw) => match raw.as_f64() {
391 Some(seconds) if seconds > 0.0 => Some(seconds),
392 _ => {
393 return Err(format!(
394 "server \"{name}\": timeout must be a positive number of seconds"
395 ));
396 }
397 },
398 };
399 let kind = value.get("type").and_then(Value::as_str);
400 if kind == Some("sse") {
401 return Err(format!(
402 "server \"{name}\": legacy SSE transport is not supported; use the streamable HTTP URL"
403 ));
404 }
405 let base = |transport| ServerConfig {
406 transport,
407 exposure,
408 description: description.clone(),
409 tool_exposure: tool_exposure.clone(),
410 enabled,
411 timeout,
412 };
413 let kind_is = |names: &[&str]| {
414 value.get("type").is_none() || kind.is_some_and(|kind| names.contains(&kind))
415 };
416 if let Some(url) = value.get("url").and_then(Value::as_str)
417 && kind_is(&["http", "streamable-http"])
418 {
419 let parsed = Url::parse(url)
420 .ok()
421 .filter(|url| url.scheme() == "http" || url.scheme() == "https")
422 .ok_or_else(|| format!("server \"{name}\": url must be an http or https URL"))?;
423 let headers = match value.get("headers") {
424 None => IndexMap::new(),
425 Some(raw) => string_map(raw)
426 .ok_or_else(|| format!("server \"{name}\": headers must map names to strings"))?,
427 };
428 if let Some(oauth) = value.get("oauth")
429 && let Some(problem) = validate_oauth(oauth)
430 {
431 return Err(format!("server \"{name}\": {problem}"));
432 }
433 let oauth = value.get("oauth").map(|oauth| {
434 let string = |key: &str| oauth.get(key).and_then(Value::as_str).map(str::to_owned);
435 OAuthConfig {
436 client_id: string("clientId"),
437 client_secret: string("clientSecret"),
438 callback_port: oauth
440 .get("callbackPort")
441 .and_then(Value::as_f64)
442 .map(|port| port as u16),
443 callback_url: string("callbackUrl"),
444 scope: string("scope"),
445 client_name: string("clientName"),
446 auth_server_metadata_url: string("authServerMetadataUrl"),
447 }
448 });
449 let auth_provider = match value.get("auth") {
450 None => None,
451 Some(auth) => {
452 let provider = auth
453 .get("provider")
454 .and_then(Value::as_str)
455 .filter(|provider| !provider.is_empty())
456 .ok_or_else(|| {
457 format!("server \"{name}\": auth.provider must be a provider name")
458 })?;
459 if parsed.scheme() != "https" && !LOOPBACK_HOSTS.contains(&host(&parsed).as_str()) {
460 return Err(format!(
461 "server \"{name}\": auth requires an https URL, or http on localhost, 127.0.0.1, or [::1]"
462 ));
463 }
464 Some(provider.to_owned())
465 }
466 };
467 return Ok(base(ServerTransport::Http {
468 url: url.to_owned(),
469 headers,
470 auth_provider,
471 oauth,
472 }));
473 }
474 if let Some(command) = value.get("command").and_then(Value::as_str)
475 && kind_is(&["stdio"])
476 {
477 let args = match value.get("args") {
478 None => Vec::new(),
479 Some(Value::Array(items)) if items.iter().all(Value::is_string) => items
480 .iter()
481 .filter_map(|item| item.as_str().map(str::to_owned))
482 .collect(),
483 Some(_) => {
484 return Err(format!(
485 "server \"{name}\": args must be an array of strings"
486 ));
487 }
488 };
489 let env = match value.get("env") {
490 None => IndexMap::new(),
491 Some(raw) => string_map(raw)
492 .ok_or_else(|| format!("server \"{name}\": env must map names to strings"))?,
493 };
494 let cwd = match value.get("cwd") {
495 None => None,
496 Some(Value::String(cwd)) => Some(cwd.clone()),
497 Some(_) => return Err(format!("server \"{name}\": cwd must be a string")),
498 };
499 return Ok(base(ServerTransport::Stdio {
500 command: command.to_owned(),
501 args,
502 env,
503 cwd,
504 }));
505 }
506 Err(format!(
507 "server \"{name}\" needs either \"command\" (stdio) or \"url\" (streamable HTTP)"
508 ))
509}
510
511fn read_file(path: &Path, scope: Scope, keep: Option<&[String]>, loaded: &mut LoadedConfig) {
512 let Ok(text) = std::fs::read_to_string(path) else {
513 return;
514 };
515 let parsed: Map<String, Value> = match serde_json::from_str::<Value>(&text) {
516 Ok(Value::Object(parsed)) if parsed.get("mcpServers").is_none_or(Value::is_object) => {
517 parsed
518 }
519 Ok(_) => {
520 loaded.errors.push(format!(
521 "{}: expected an object with an \"mcpServers\" object",
522 path.display()
523 ));
524 return;
525 }
526 Err(error) => {
527 loaded.errors.push(format!("{}: {error}", path.display()));
528 return;
529 }
530 };
531 match parsed.get("autoEnableCodemode") {
532 None => {}
533 Some(Value::Bool(value)) => loaded.auto_enable_codemode = Some(*value),
534 Some(_) => loaded.errors.push(format!(
535 "{}: autoEnableCodemode must be a boolean",
536 path.display()
537 )),
538 }
539 let empty = Map::new();
540 let servers = parsed
541 .get("mcpServers")
542 .and_then(Value::as_object)
543 .unwrap_or(&empty);
544 let kept = |name: &str| {
547 keep.is_none_or(|keep| {
548 keep.iter()
549 .any(|pattern| crate::glob::matches(pattern, name))
550 })
551 };
552 for (name, raw) in servers.iter().filter(|(name, _)| kept(name)) {
553 let config = match validate_server(name, raw) {
554 Ok(config) => config,
555 Err(problem) => {
556 loaded.errors.push(format!("{}: {problem}", path.display()));
557 continue;
558 }
559 };
560 if let Some(clash) = loaded
562 .servers
563 .iter()
564 .find(|other| other.name != *name && namespace(&other.name) == namespace(name))
565 {
566 loaded.errors.push(format!(
567 "{}: server \"{name}\" conflicts with \"{}\"",
568 path.display(),
569 clash.name
570 ));
571 continue;
572 }
573 if scope == Scope::Project
574 && matches!(
575 config.transport,
576 ServerTransport::Http {
577 auth_provider: Some(_),
578 ..
579 }
580 )
581 {
582 loaded.errors.push(format!(
583 "{}: server \"{name}\": auth is only allowed in the global mcp.json",
584 path.display()
585 ));
586 continue;
587 }
588 let entry = ServerEntry {
589 name: name.clone(),
590 config,
591 source: path.to_path_buf(),
592 scope,
593 };
594 match loaded.servers.iter_mut().find(|other| other.name == *name) {
595 Some(existing) => *existing = entry,
596 None => loaded.servers.push(entry),
597 }
598 }
599}
600
601pub fn load(
606 agent_dir: &Path,
607 project_dir: &Path,
608 project_trusted: bool,
609 keep: Option<&[String]>,
610) -> LoadedConfig {
611 let mut loaded = LoadedConfig::default();
612 let name = ConfigFile::Mcp.file_name();
613 read_file(&agent_dir.join(name), Scope::Global, keep, &mut loaded);
614 if project_trusted {
615 read_file(
616 &project_dir.join(PROJECT_DIR).join(name),
617 Scope::Project,
618 None,
619 &mut loaded,
620 );
621 }
622 loaded
623}
624
625pub fn resolve_exposure_aliases(raw: &Value) -> Value {
628 let canonical = |value: &Value| match McpExposure::parse(value) {
629 Some(exposure) => Value::String(exposure.as_str().to_owned()),
630 None => value.clone(),
631 };
632 let mut resolved = raw.clone();
633 if let Some(object) = resolved.as_object_mut() {
634 if let Some(exposure) = object.get_mut("exposure") {
635 *exposure = canonical(exposure);
636 }
637 if let Some(Value::Object(tools)) = object.get_mut("toolExposure") {
638 for value in tools.values_mut() {
639 *value = canonical(value);
640 }
641 }
642 }
643 resolved
644}
645
646pub fn add_server_config(path: &Path, name: &str, config: Value) -> Result<bool, String> {
650 let mut replaced = false;
651 edit_servers(path, |document| {
652 let servers = document
653 .entry("mcpServers")
654 .or_insert_with(|| Value::Object(Map::new()));
655 if let Value::Object(servers) = servers {
656 replaced = servers.insert(name.to_owned(), config).is_some();
657 }
658 true
659 })?;
660 Ok(replaced)
661}
662
663pub fn remove_server_config(path: &Path, name: &str) -> Result<bool, String> {
666 if !path.exists() {
667 return Ok(false);
668 }
669 let mut removed = false;
670 edit_servers(path, |document| {
671 removed = document
672 .get_mut("mcpServers")
673 .and_then(Value::as_object_mut)
674 .is_some_and(|servers| servers.shift_remove(name).is_some());
675 removed
676 })?;
677 Ok(removed)
678}
679
680#[derive(Clone, Copy, Debug, PartialEq, Eq)]
682pub enum ConfigPatch {
683 Enabled(bool),
685 Exposure(McpExposure),
687}
688
689impl ConfigPatch {
690 pub fn apply(self, config: &mut ServerConfig) {
692 match self {
693 ConfigPatch::Enabled(enabled) => config.enabled = Some(enabled),
694 ConfigPatch::Exposure(exposure) => config.exposure = Some(exposure),
695 }
696 }
697}
698
699pub fn update_server_config(path: &Path, name: &str, patch: ConfigPatch) -> Result<(), String> {
703 let mut found = false;
704 edit_servers(path, |document| {
705 let Some(Value::Object(server)) = document
706 .get_mut("mcpServers")
707 .and_then(|servers| servers.get_mut(name))
708 else {
709 return false;
710 };
711 found = true;
712 let (key, value) = match patch {
713 ConfigPatch::Enabled(enabled) => ("enabled", (!enabled).then_some(Value::Bool(false))),
714 ConfigPatch::Exposure(exposure) => (
715 "exposure",
716 (exposure != McpExposure::Codemode).then(|| Value::from(exposure.as_str())),
717 ),
718 };
719 match value {
720 Some(value) => {
721 server.insert(key.to_owned(), value);
722 }
723 None => {
724 server.shift_remove(key);
725 }
726 }
727 true
728 })?;
729 if found {
730 Ok(())
731 } else {
732 Err(format!(
733 "{} does not define MCP server \"{name}\"",
734 path.display()
735 ))
736 }
737}
738
739fn edit_servers(
743 path: &Path,
744 edit: impl FnOnce(&mut Map<String, Value>) -> bool,
745) -> Result<(), String> {
746 let text = std::fs::read_to_string(path).ok();
747 let parsed: Value = match &text {
748 Some(text) => serde_json::from_str(text).map_err(|err| err.to_string())?,
749 None => Value::Object(Map::new()),
750 };
751 let Value::Object(mut document) = parsed else {
752 return Err(format!(
753 "{}: expected an object with an \"mcpServers\" object",
754 path.display()
755 ));
756 };
757 if document
758 .get("mcpServers")
759 .is_some_and(|servers| !servers.is_object())
760 {
761 return Err(format!(
762 "{}: expected an object with an \"mcpServers\" object",
763 path.display()
764 ));
765 }
766 if !edit(&mut document) {
767 return Ok(());
768 }
769 let indent = text
771 .as_deref()
772 .and_then(|text| {
773 text.lines().find_map(|line| {
774 let rest = line.trim_start_matches([' ', '\t']);
775 (rest.len() < line.len()
776 && !rest.is_empty()
777 && !rest.starts_with(char::is_whitespace))
778 .then(|| &line[..line.len() - rest.len()])
779 })
780 })
781 .unwrap_or(" ");
782 let json = yapi_types::json::to_string_pretty(&Value::Object(document), indent)
783 .map_err(|err| err.to_string())?;
784 if let Some(parent) = path.parent() {
785 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
786 }
787 yapi_ai::auth::lock::write_atomic(path, format!("{json}\n").as_bytes(), 0o666)
788 .map_err(|err| err.to_string())
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use serde_json::json;
795
796 #[test]
797 fn validates_like_pi() {
798 let stdio = validate_server(
799 "fs",
800 &json!({"command": "npx", "args": ["-y", "x"], "exposure": "codemode-deferred"}),
801 )
802 .unwrap();
803 assert_eq!(stdio.exposure, Some(McpExposure::Codemode));
804 let problems = [
805 (
806 json!({"command": 1}),
807 "server \"x\" needs either \"command\" (stdio) or \"url\" (streamable HTTP)",
808 ),
809 (
810 json!({"url": "ftp://a"}),
811 "server \"x\": url must be an http or https URL",
812 ),
813 (
814 json!({"url": "https://a", "type": "sse"}),
815 "server \"x\": legacy SSE transport is not supported; use the streamable HTTP URL",
816 ),
817 (
818 json!({"command": "a", "exposure": "all"}),
819 "server \"x\": exposure must be one of \"codemode\", \"deferred\", \"direct\", \"hidden\"",
820 ),
821 (
822 json!({"command": "a", "timeout": 0}),
823 "server \"x\": timeout must be a positive number of seconds",
824 ),
825 (
826 json!({"url": "http://example.com", "auth": {"provider": "github"}}),
827 "server \"x\": auth requires an https URL, or http on localhost, 127.0.0.1, or [::1]",
828 ),
829 (
830 json!({"url": "https://a", "oauth": {"callbackUrl": "http://localhost:1/cb", "callbackPort": 2}}),
831 "server \"x\": oauth.callbackUrl and oauth.callbackPort name different ports",
832 ),
833 ];
834 for (raw, message) in problems {
835 assert_eq!(validate_server("x", &raw).unwrap_err(), message);
836 }
837 assert_eq!(
838 validate_server("a b", &json!({})).unwrap_err(),
839 "invalid server name \"a b\" (use letters, digits, \"_\" and \"-\")"
840 );
841 assert!(
842 validate_server(
843 "x",
844 &json!({"url": "http://[::1]:8080/mcp", "auth": {"provider": "p"}})
845 )
846 .is_ok()
847 );
848 }
849
850 #[test]
851 fn project_auth_refused() {
852 let dir = std::env::temp_dir().join(format!("yapi-mcp-load-{}", std::process::id()));
853 let _ = std::fs::remove_dir_all(&dir);
854 let path = dir.join(PROJECT_DIR).join("mcp.json");
855 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
856 std::fs::write(
857 &path,
858 r#"{"mcpServers":{"x":{"url":"https://example.invalid/mcp","auth":{"provider":"anthropic"}},"y":{"command":"a"}}}"#,
859 )
860 .unwrap();
861 let agent_dir = dir.join("agent");
862 let loaded = load(&agent_dir, &dir, true, None);
863 assert_eq!(
864 loaded.errors,
865 [format!(
866 "{}: server \"x\": auth is only allowed in the global mcp.json",
867 path.display()
868 )]
869 );
870 let names: Vec<&str> = loaded.servers.iter().map(|s| s.name.as_str()).collect();
871 assert_eq!(names, ["y"]);
872 let untrusted = load(&agent_dir, &dir, false, None);
874 assert!(untrusted.servers.is_empty() && untrusted.errors.is_empty());
875
876 std::fs::create_dir_all(&agent_dir).unwrap();
880 std::fs::write(
881 agent_dir.join("mcp.json"),
882 r#"{"mcpServers":{"github":{"url":"https://example.invalid/mcp","auth":{"provider":"github"}},"slack":{"command":"a"},"my-y":{"command":"a"},"broken":{}}}"#,
883 )
884 .unwrap();
885 std::fs::write(&path, r#"{"mcpServers":{"my_y":{"command":"b"}}}"#).unwrap();
886 let keep = ["git*".to_owned()];
887 let kept = load(&agent_dir, &dir, true, Some(&keep));
888 assert!(kept.errors.is_empty(), "{:?}", kept.errors);
889 let names: Vec<&str> = kept.servers.iter().map(|s| s.name.as_str()).collect();
890 assert_eq!(names, ["github", "my_y"]);
891 let _ = std::fs::remove_dir_all(&dir);
892 }
893
894 #[test]
895 fn tool_exposure_prefers_exact_names() {
896 let config = validate_server(
897 "gh",
898 &json!({"command": "a", "exposure": "hidden", "toolExposure": {"list_*": "direct", "list_issues": "deferred", "*_pr": "codemode"}}),
899 )
900 .unwrap();
901 assert_eq!(config.tool_exposure("list_issues"), McpExposure::Deferred);
902 assert_eq!(config.tool_exposure("list_repos"), McpExposure::Direct);
903 assert_eq!(config.tool_exposure("merge_pr"), McpExposure::Codemode);
904 assert_eq!(config.tool_exposure("delete"), McpExposure::Hidden);
905 assert_eq!(namespace("my-server"), "mcp__my_server");
906 }
907
908 #[test]
909 fn edits_servers_keeping_indentation() {
910 let dir = std::env::temp_dir().join(format!("yapi-mcp-edit-{}", std::process::id()));
911 let _ = std::fs::remove_dir_all(&dir);
912 let path = dir.join("mcp.json");
913 assert!(!remove_server_config(&path, "x").unwrap());
914 assert!(!add_server_config(&path, "a", json!({"command": "a"})).unwrap());
915 assert_eq!(
916 std::fs::read_to_string(&path).unwrap(),
917 "{\n \"mcpServers\": {\n \"a\": {\n \"command\": \"a\"\n }\n }\n}\n"
918 );
919 std::fs::write(
920 &path,
921 "{\n\t\"other\": 1,\n\t\"mcpServers\": {\"a\": {\"url\": \"https://x\"}}\n}",
922 )
923 .unwrap();
924 assert!(add_server_config(&path, "a", json!({"command": "b"})).unwrap());
925 assert!(remove_server_config(&path, "a").unwrap());
926 assert_eq!(
927 std::fs::read_to_string(&path).unwrap(),
928 "{\n\t\"other\": 1,\n\t\"mcpServers\": {}\n}\n"
929 );
930 assert_eq!(
931 update_server_config(&path, "a", ConfigPatch::Enabled(false)).unwrap_err(),
932 format!("{} does not define MCP server \"a\"", path.display())
933 );
934 add_server_config(&path, "a", json!({"command": "a", "enabled": false})).unwrap();
935 update_server_config(&path, "a", ConfigPatch::Exposure(McpExposure::Direct)).unwrap();
936 update_server_config(&path, "a", ConfigPatch::Enabled(true)).unwrap();
937 assert_eq!(
938 std::fs::read_to_string(&path).unwrap(),
939 "{\n\t\"other\": 1,\n\t\"mcpServers\": {\n\t\t\"a\": {\n\t\t\t\"command\": \"a\",\n\t\t\t\"exposure\": \"direct\"\n\t\t}\n\t}\n}\n"
940 );
941 assert_eq!(
942 resolve_exposure_aliases(
943 &json!({"exposure": "codemode-deferred", "toolExposure": {"t": "codemode-deferred"}})
944 ),
945 json!({"exposure": "codemode", "toolExposure": {"t": "codemode"}})
946 );
947 let _ = std::fs::remove_dir_all(&dir);
948 }
949}