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, 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 for (name, raw) in servers {
545 let config = match validate_server(name, raw) {
546 Ok(config) => config,
547 Err(problem) => {
548 loaded.errors.push(format!("{}: {problem}", path.display()));
549 continue;
550 }
551 };
552 if let Some(clash) = loaded
554 .servers
555 .iter()
556 .find(|other| other.name != *name && namespace(&other.name) == namespace(name))
557 {
558 loaded.errors.push(format!(
559 "{}: server \"{name}\" conflicts with \"{}\"",
560 path.display(),
561 clash.name
562 ));
563 continue;
564 }
565 if scope == Scope::Project
566 && matches!(
567 config.transport,
568 ServerTransport::Http {
569 auth_provider: Some(_),
570 ..
571 }
572 )
573 {
574 loaded.errors.push(format!(
575 "{}: server \"{name}\": auth is only allowed in the global mcp.json",
576 path.display()
577 ));
578 continue;
579 }
580 let entry = ServerEntry {
581 name: name.clone(),
582 config,
583 source: path.to_path_buf(),
584 scope,
585 };
586 match loaded.servers.iter_mut().find(|other| other.name == *name) {
587 Some(existing) => *existing = entry,
588 None => loaded.servers.push(entry),
589 }
590 }
591}
592
593pub fn load(agent_dir: &Path, project_dir: &Path, project_trusted: bool) -> LoadedConfig {
596 let mut loaded = LoadedConfig::default();
597 let name = ConfigFile::Mcp.file_name();
598 read_file(&agent_dir.join(name), Scope::Global, &mut loaded);
599 if project_trusted {
600 read_file(
601 &project_dir.join(PROJECT_DIR).join(name),
602 Scope::Project,
603 &mut loaded,
604 );
605 }
606 loaded
607}
608
609pub fn resolve_exposure_aliases(raw: &Value) -> Value {
612 let canonical = |value: &Value| match McpExposure::parse(value) {
613 Some(exposure) => Value::String(exposure.as_str().to_owned()),
614 None => value.clone(),
615 };
616 let mut resolved = raw.clone();
617 if let Some(object) = resolved.as_object_mut() {
618 if let Some(exposure) = object.get_mut("exposure") {
619 *exposure = canonical(exposure);
620 }
621 if let Some(Value::Object(tools)) = object.get_mut("toolExposure") {
622 for value in tools.values_mut() {
623 *value = canonical(value);
624 }
625 }
626 }
627 resolved
628}
629
630pub fn add_server_config(path: &Path, name: &str, config: Value) -> Result<bool, String> {
634 let mut replaced = false;
635 edit_servers(path, |document| {
636 let servers = document
637 .entry("mcpServers")
638 .or_insert_with(|| Value::Object(Map::new()));
639 if let Value::Object(servers) = servers {
640 replaced = servers.insert(name.to_owned(), config).is_some();
641 }
642 true
643 })?;
644 Ok(replaced)
645}
646
647pub fn remove_server_config(path: &Path, name: &str) -> Result<bool, String> {
650 if !path.exists() {
651 return Ok(false);
652 }
653 let mut removed = false;
654 edit_servers(path, |document| {
655 removed = document
656 .get_mut("mcpServers")
657 .and_then(Value::as_object_mut)
658 .is_some_and(|servers| servers.shift_remove(name).is_some());
659 removed
660 })?;
661 Ok(removed)
662}
663
664#[derive(Clone, Copy, Debug, PartialEq, Eq)]
666pub enum ConfigPatch {
667 Enabled(bool),
669 Exposure(McpExposure),
671}
672
673impl ConfigPatch {
674 pub fn apply(self, config: &mut ServerConfig) {
676 match self {
677 ConfigPatch::Enabled(enabled) => config.enabled = Some(enabled),
678 ConfigPatch::Exposure(exposure) => config.exposure = Some(exposure),
679 }
680 }
681}
682
683pub fn update_server_config(path: &Path, name: &str, patch: ConfigPatch) -> Result<(), String> {
687 let mut found = false;
688 edit_servers(path, |document| {
689 let Some(Value::Object(server)) = document
690 .get_mut("mcpServers")
691 .and_then(|servers| servers.get_mut(name))
692 else {
693 return false;
694 };
695 found = true;
696 let (key, value) = match patch {
697 ConfigPatch::Enabled(enabled) => ("enabled", (!enabled).then_some(Value::Bool(false))),
698 ConfigPatch::Exposure(exposure) => (
699 "exposure",
700 (exposure != McpExposure::Codemode).then(|| Value::from(exposure.as_str())),
701 ),
702 };
703 match value {
704 Some(value) => {
705 server.insert(key.to_owned(), value);
706 }
707 None => {
708 server.shift_remove(key);
709 }
710 }
711 true
712 })?;
713 if found {
714 Ok(())
715 } else {
716 Err(format!(
717 "{} does not define MCP server \"{name}\"",
718 path.display()
719 ))
720 }
721}
722
723fn edit_servers(
727 path: &Path,
728 edit: impl FnOnce(&mut Map<String, Value>) -> bool,
729) -> Result<(), String> {
730 let text = std::fs::read_to_string(path).ok();
731 let parsed: Value = match &text {
732 Some(text) => serde_json::from_str(text).map_err(|err| err.to_string())?,
733 None => Value::Object(Map::new()),
734 };
735 let Value::Object(mut document) = parsed else {
736 return Err(format!(
737 "{}: expected an object with an \"mcpServers\" object",
738 path.display()
739 ));
740 };
741 if document
742 .get("mcpServers")
743 .is_some_and(|servers| !servers.is_object())
744 {
745 return Err(format!(
746 "{}: expected an object with an \"mcpServers\" object",
747 path.display()
748 ));
749 }
750 if !edit(&mut document) {
751 return Ok(());
752 }
753 let indent = text
755 .as_deref()
756 .and_then(|text| {
757 text.lines().find_map(|line| {
758 let rest = line.trim_start_matches([' ', '\t']);
759 (rest.len() < line.len()
760 && !rest.is_empty()
761 && !rest.starts_with(char::is_whitespace))
762 .then(|| &line[..line.len() - rest.len()])
763 })
764 })
765 .unwrap_or(" ");
766 let json = yapi_types::json::to_string_pretty(&Value::Object(document), indent)
767 .map_err(|err| err.to_string())?;
768 if let Some(parent) = path.parent() {
769 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
770 }
771 yapi_ai::auth::lock::write_atomic(path, format!("{json}\n").as_bytes(), 0o666)
772 .map_err(|err| err.to_string())
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use serde_json::json;
779
780 #[test]
781 fn validates_like_pi() {
782 let stdio = validate_server(
783 "fs",
784 &json!({"command": "npx", "args": ["-y", "x"], "exposure": "codemode-deferred"}),
785 )
786 .unwrap();
787 assert_eq!(stdio.exposure, Some(McpExposure::Codemode));
788 let problems = [
789 (
790 json!({"command": 1}),
791 "server \"x\" needs either \"command\" (stdio) or \"url\" (streamable HTTP)",
792 ),
793 (
794 json!({"url": "ftp://a"}),
795 "server \"x\": url must be an http or https URL",
796 ),
797 (
798 json!({"url": "https://a", "type": "sse"}),
799 "server \"x\": legacy SSE transport is not supported; use the streamable HTTP URL",
800 ),
801 (
802 json!({"command": "a", "exposure": "all"}),
803 "server \"x\": exposure must be one of \"codemode\", \"deferred\", \"direct\", \"hidden\"",
804 ),
805 (
806 json!({"command": "a", "timeout": 0}),
807 "server \"x\": timeout must be a positive number of seconds",
808 ),
809 (
810 json!({"url": "http://example.com", "auth": {"provider": "github"}}),
811 "server \"x\": auth requires an https URL, or http on localhost, 127.0.0.1, or [::1]",
812 ),
813 (
814 json!({"url": "https://a", "oauth": {"callbackUrl": "http://localhost:1/cb", "callbackPort": 2}}),
815 "server \"x\": oauth.callbackUrl and oauth.callbackPort name different ports",
816 ),
817 ];
818 for (raw, message) in problems {
819 assert_eq!(validate_server("x", &raw).unwrap_err(), message);
820 }
821 assert_eq!(
822 validate_server("a b", &json!({})).unwrap_err(),
823 "invalid server name \"a b\" (use letters, digits, \"_\" and \"-\")"
824 );
825 assert!(
826 validate_server(
827 "x",
828 &json!({"url": "http://[::1]:8080/mcp", "auth": {"provider": "p"}})
829 )
830 .is_ok()
831 );
832 }
833
834 #[test]
835 fn project_auth_refused() {
836 let dir = std::env::temp_dir().join(format!("yapi-mcp-load-{}", std::process::id()));
837 let _ = std::fs::remove_dir_all(&dir);
838 let path = dir.join(PROJECT_DIR).join("mcp.json");
839 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
840 std::fs::write(
841 &path,
842 r#"{"mcpServers":{"x":{"url":"https://example.invalid/mcp","auth":{"provider":"anthropic"}},"y":{"command":"a"}}}"#,
843 )
844 .unwrap();
845 let agent_dir = dir.join("agent");
846 let loaded = load(&agent_dir, &dir, true);
847 assert_eq!(
848 loaded.errors,
849 [format!(
850 "{}: server \"x\": auth is only allowed in the global mcp.json",
851 path.display()
852 )]
853 );
854 let names: Vec<&str> = loaded.servers.iter().map(|s| s.name.as_str()).collect();
855 assert_eq!(names, ["y"]);
856 let untrusted = load(&agent_dir, &dir, false);
858 assert!(untrusted.servers.is_empty() && untrusted.errors.is_empty());
859 let _ = std::fs::remove_dir_all(&dir);
860 }
861
862 #[test]
863 fn tool_exposure_prefers_exact_names() {
864 let config = validate_server(
865 "gh",
866 &json!({"command": "a", "exposure": "hidden", "toolExposure": {"list_*": "direct", "list_issues": "deferred", "*_pr": "codemode"}}),
867 )
868 .unwrap();
869 assert_eq!(config.tool_exposure("list_issues"), McpExposure::Deferred);
870 assert_eq!(config.tool_exposure("list_repos"), McpExposure::Direct);
871 assert_eq!(config.tool_exposure("merge_pr"), McpExposure::Codemode);
872 assert_eq!(config.tool_exposure("delete"), McpExposure::Hidden);
873 assert_eq!(namespace("my-server"), "mcp__my_server");
874 }
875
876 #[test]
877 fn edits_servers_keeping_indentation() {
878 let dir = std::env::temp_dir().join(format!("yapi-mcp-edit-{}", std::process::id()));
879 let _ = std::fs::remove_dir_all(&dir);
880 let path = dir.join("mcp.json");
881 assert!(!remove_server_config(&path, "x").unwrap());
882 assert!(!add_server_config(&path, "a", json!({"command": "a"})).unwrap());
883 assert_eq!(
884 std::fs::read_to_string(&path).unwrap(),
885 "{\n \"mcpServers\": {\n \"a\": {\n \"command\": \"a\"\n }\n }\n}\n"
886 );
887 std::fs::write(
888 &path,
889 "{\n\t\"other\": 1,\n\t\"mcpServers\": {\"a\": {\"url\": \"https://x\"}}\n}",
890 )
891 .unwrap();
892 assert!(add_server_config(&path, "a", json!({"command": "b"})).unwrap());
893 assert!(remove_server_config(&path, "a").unwrap());
894 assert_eq!(
895 std::fs::read_to_string(&path).unwrap(),
896 "{\n\t\"other\": 1,\n\t\"mcpServers\": {}\n}\n"
897 );
898 assert_eq!(
899 update_server_config(&path, "a", ConfigPatch::Enabled(false)).unwrap_err(),
900 format!("{} does not define MCP server \"a\"", path.display())
901 );
902 add_server_config(&path, "a", json!({"command": "a", "enabled": false})).unwrap();
903 update_server_config(&path, "a", ConfigPatch::Exposure(McpExposure::Direct)).unwrap();
904 update_server_config(&path, "a", ConfigPatch::Enabled(true)).unwrap();
905 assert_eq!(
906 std::fs::read_to_string(&path).unwrap(),
907 "{\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"
908 );
909 assert_eq!(
910 resolve_exposure_aliases(
911 &json!({"exposure": "codemode-deferred", "toolExposure": {"t": "codemode-deferred"}})
912 ),
913 json!({"exposure": "codemode", "toolExposure": {"t": "codemode"}})
914 );
915 let _ = std::fs::remove_dir_all(&dir);
916 }
917}