Skip to main content

yapi_core/mcp/
config.rs

1//! `mcp.json`: the MCP servers of the agent directory and, for trusted
2//! projects, of `<project>/.yapi/mcp.json`, in the `mcpServers` shape other MCP
3//! clients share. Project entries replace global ones of the same name. Port
4//! of `extensions/mcp/config.ts` and `core/mcp-servers.ts` in pi `v1.0.0`.
5
6use 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/// pi's `McpExposure`: how an MCP server's tools reach the model.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum McpExposure {
19    /// Called from codemode scripts. The default.
20    Codemode,
21    /// Declared once `tool_search` loads them.
22    Deferred,
23    /// Declared to the model like built-in tools.
24    Direct,
25    /// Unreachable.
26    Hidden,
27}
28
29impl McpExposure {
30    /// The configuration name.
31    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    /// Parses a configuration name or its alias.
41    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    /// The tool exposure: `codemode` tools are deferred tools that codemode
52    /// reaches. The two differ only in which discovery tool is activated.
53    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/// How to reach a server.
64#[derive(Clone, Debug, PartialEq)]
65pub enum ServerTransport {
66    /// A process speaking JSON-RPC on stdin and stdout.
67    Stdio {
68        /// The program.
69        command: String,
70        /// Its arguments.
71        args: Vec<String>,
72        /// Environment values, which may reference variables or commands.
73        env: IndexMap<String, String>,
74        /// Working directory, relative to the session's.
75        cwd: Option<String>,
76    },
77    /// A streamable HTTP endpoint.
78    Http {
79        /// The URL.
80        url: String,
81        /// Header values, which may reference variables or commands.
82        headers: IndexMap<String, String>,
83        /// The `/login` provider whose token is sent.
84        auth_provider: Option<String>,
85        /// OAuth client settings.
86        oauth: Option<OAuthConfig>,
87    },
88}
89
90/// pi's `McpOAuthConfig`: OAuth client settings for servers that do not
91/// support dynamic client registration.
92#[derive(Clone, Debug, Default, PartialEq)]
93pub struct OAuthConfig {
94    /// Pre-registered client id.
95    pub client_id: Option<String>,
96    /// Client secret, which may reference variables or commands.
97    pub client_secret: Option<String>,
98    /// Port of the loopback callback server.
99    pub callback_port: Option<u16>,
100    /// Redirect URI registered for `client_id`.
101    pub callback_url: Option<String>,
102    /// Scopes to request, separated by spaces.
103    pub scope: Option<String>,
104    /// `client_name` for dynamic client registration.
105    pub client_name: Option<String>,
106    /// Authorization server metadata document to use instead of discovery.
107    pub auth_server_metadata_url: Option<String>,
108}
109
110impl ServerTransport {
111    /// Whether the server would sign in with OAuth: HTTP without an
112    /// `Authorization` header or provider auth.
113    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/// One validated server entry.
120#[derive(Clone, Debug, PartialEq)]
121pub struct ServerConfig {
122    /// Transport settings.
123    pub transport: ServerTransport,
124    /// Default exposure of its tools.
125    pub exposure: Option<McpExposure>,
126    /// What the server offers, in a sentence.
127    pub description: Option<String>,
128    /// Exposure of single tools by name or `*` pattern, in file order.
129    pub tool_exposure: IndexMap<String, McpExposure>,
130    /// `false` keeps the entry without connecting.
131    pub enabled: Option<bool>,
132    /// Per-request timeout in seconds.
133    pub timeout: Option<f64>,
134}
135
136impl ServerConfig {
137    /// The server's exposure, `codemode` by default.
138    pub fn exposure(&self) -> McpExposure {
139        self.exposure.unwrap_or(McpExposure::Codemode)
140    }
141
142    /// Whether it connects.
143    pub fn is_enabled(&self) -> bool {
144        self.enabled != Some(false)
145    }
146
147    /// pi's `getMcpToolExposure`: an exact `toolExposure` entry, else the
148    /// first matching pattern, else the server's exposure.
149    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    /// Every exposure its tools can have, known before it connects.
162    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
173/// pi's `toolPatternRegExp`: `*` matches any characters.
174fn 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/// Which file defined a server.
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub enum Scope {
183    /// The agent directory's `mcp.json`.
184    Global,
185    /// The project's `mcp.json`.
186    Project,
187    /// Registered by the extension at `source` with `pi.registerMcpServer`.
188    Extension,
189}
190
191impl Scope {
192    /// pi's name for the scope.
193    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/// A configured server.
203#[derive(Clone, Debug, PartialEq)]
204pub struct ServerEntry {
205    /// Its name.
206    pub name: String,
207    /// Its settings.
208    pub config: ServerConfig,
209    /// The file that defined it.
210    pub source: PathBuf,
211    /// Which file that is.
212    pub scope: Scope,
213}
214
215impl ServerEntry {
216    /// pi's `describeTransport`: the URL, or the command line.
217    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/// Everything `mcp.json` files configure, and their problems.
230#[derive(Clone, Debug, Default, PartialEq)]
231pub struct LoadedConfig {
232    /// Servers in definition order.
233    pub servers: Vec<ServerEntry>,
234    /// Whether codemode is activated for `codemode` servers.
235    pub auto_enable_codemode: Option<bool>,
236    /// Problems, each prefixed with its file.
237    pub errors: Vec<String>,
238}
239
240/// `mcp__<server>` with `-` as `_`, the prefix of the server's tool names.
241pub fn namespace(server: &str) -> String {
242    format!("mcp__{}", server.replace('-', "_"))
243}
244
245/// pi's checks of server `name`, which an extension registers, against the
246/// servers that extensions of other runtimes registered: neither the name
247/// nor its tools' namespace may be theirs. The error is pi's.
248pub 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
272/// The host as JavaScript's `URL.hostname` gives it, IPv6 in brackets.
273fn 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
342/// pi's `validateMcpServerConfig`: the entry, or an error message.
343pub 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                // Validated as a whole number from 1 to 65535.
439                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        // Names that differ only in `-` and `_` would share a namespace.
553        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
593/// Loads the global and, when trusted, the `mcp.json` of the project in
594/// `project_dir`. Disabled servers are included.
595pub 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
609/// pi's `resolveExposureAliases`: `raw` with exposure aliases, in
610/// `exposure` and `toolExposure`, replaced by their current names.
611pub 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
630/// pi's `addMcpServerConfig`: adds `config` as server `name` to the
631/// `mcp.json` at `path`, creating the file when missing. Returns whether an
632/// entry of that name was replaced.
633pub 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
647/// pi's `removeMcpServerConfig`: removes server `name` from the `mcp.json`
648/// at `path`. Returns false when the file does not define it.
649pub 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/// pi's `McpServerConfigPatch`: a setting `/mcp` changes.
665#[derive(Clone, Copy, Debug, PartialEq, Eq)]
666pub enum ConfigPatch {
667    /// `enabled`.
668    Enabled(bool),
669    /// `exposure`.
670    Exposure(McpExposure),
671}
672
673impl ConfigPatch {
674    /// Applies the patch to a loaded config.
675    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
683/// pi's `updateMcpServerConfig`: changes one setting of server `name` in the
684/// `mcp.json` at `path`. The defaults, `enabled: true` and
685/// `exposure: "codemode"`, remove the key.
686pub 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
723/// Reads an `mcp.json` (empty when missing), lets `edit` change it, and
724/// writes it back with its own indentation when `edit` returns true. Other
725/// content is kept.
726fn 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    // The first indented line's indentation, as pi detects it.
754    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        // An untrusted project's file is not read at all.
857        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}