Skip to main content

basis/
mcp.rs

1//! Discovery of `.mcp.json` — the MCP servers a workspace wants connected.
2//!
3//! Tools reach the model three ways: mentra's builtins, skills, and MCP
4//! servers. mentra owns the client half — spawning the process, the
5//! `initialize` handshake, bridging every advertised tool into the runtime's
6//! roster. basis owns the convention half: which file names a server, where that
7//! file lives, and what its fields mean. Nothing here interprets a server.
8//!
9//! # The format is not basis's
10//!
11//! An `mcpServers` object in a repo-root `.mcp.json` is what the agents that
12//! already read a project-local MCP file write, so basis reads that rather than
13//! inventing a spelling for the same idea. The one place basis is stricter: a
14//! file that exists but names no `mcpServers` key is an error, because the
15//! alternative is that a typo disables every server and says nothing.
16//!
17//! # Three places a server can come from, and no fourth
18//!
19//! In precedence order — supplied by the host, then the workspace file, then
20//! the global one. "Supplied" is an ACP client's `mcpServers` on
21//! `session/new`, or a Rust host's own list; the client is the most specific
22//! authority there is, because it is answering for this session in particular.
23//!
24//! There is deliberately **no parent walk**, which is where this module parts
25//! company with [`context`](crate::context) and [`skills`](crate::skills).
26//! Those walk from the workspace root outward, and for instructions that is
27//! right: a monorepo's house rules should reach every crate inside it, and the
28//! worst case of picking one up is prose the model did not need.
29//!
30//! `.mcp.json` is not prose. It names commands to spawn and credentials to
31//! spawn them with — it is in basis's own `.gitignore` for that reason, and in
32//! most projects' — so inheriting one from a directory the operator did not
33//! point basis at means running a program they never chose, with a token they
34//! never offered, because of where they happened to `cd`. Two roots the
35//! operator names explicitly (this workspace, their own config) are the whole
36//! set. A server in a parent directory is one `cd` away from being asked for
37//! properly.
38//!
39//! Nothing read here is ever repeated back: see [`McpError`].
40//!
41//! Names are the identity. mentra namespaces every bridged tool by its
42//! server's name, so two servers sharing a name would collide in the tool
43//! roster; a more specific one therefore *shadows* a weaker one instead of
44//! joining it.
45//!
46//! # Transports
47//!
48//! stdio, the legacy HTTP+SSE transport, and Streamable HTTP — the three
49//! mentra has clients for. Streamable HTTP is the transport current MCP
50//! servers ship; a server that answers `404` on a legacy `/sse` path wants
51//! [`McpServer::Http`]. One deliberate asymmetry survives in `.mcp.json`: a
52//! bare `url` with no `type` still means SSE, because files written before
53//! the third transport existed keep their meaning.
54//!
55//! mentra's `allow_plaintext_credentials` override is deliberately not a
56//! `.mcp.json` key. A committed file must not be able to grant its own
57//! headers plaintext passage to a non-loopback host — the same line drawn at
58//! `base_url` — so the refusal stays mentra's, checked at parse, and the
59//! override stays with hosts that construct the config in code.
60
61pub(crate) mod connections;
62mod file;
63
64use std::path::{Path, PathBuf};
65
66use thiserror::Error;
67
68use crate::context::ContextScope;
69
70pub use mentra::mcp::SecretString;
71pub use mentra::{
72    McpServerConfig, McpSseLimits, McpSseServerConfig, McpStreamableHttpConfigError,
73    McpStreamableHttpLimits, McpStreamableHttpServerConfig,
74};
75
76/// Where basis looks inside a workspace, relative to its root.
77pub const DEFAULT_WORKSPACE_MCP_FILE: &str = ".mcp.json";
78
79/// Where basis looks inside the global config directory. Not the dotted name:
80/// a hidden file inside a directory that exists to hold configuration would be
81/// hiding it from the person who put it there.
82pub const DEFAULT_GLOBAL_MCP_FILE: &str = "mcp.json";
83
84/// One MCP server, as basis hands it to mentra.
85///
86/// A thin sum over mentra's three transport configurations rather than a type
87/// of basis's own: mentra owns what a server *is*, and re-describing it here would
88/// only create something to drift. The enum exists because mentra's own
89/// equivalent is private, so a caller holding a mixed list has nowhere to put
90/// it (see the module docs on transports).
91#[derive(Clone)]
92#[non_exhaustive]
93pub enum McpServer {
94    /// A child process speaking JSON-RPC over its standard streams.
95    Stdio(McpServerConfig),
96    /// The legacy HTTP+SSE transport from protocol revision 2024-11-05.
97    Sse(McpSseServerConfig),
98    /// The Streamable HTTP transport, which current MCP servers ship.
99    Http(McpStreamableHttpServerConfig),
100}
101
102/// Hand-written for the reason [`McpError`] reports so little: a stdio
103/// server's `env` holds credentials, and by the time one reaches this type basis
104/// has already expanded `${GITHUB_TOKEN}` into the real value. Deriving would
105/// put those in every `{:?}` of an [`McpConfig`] or an [`McpSource`] — both
106/// of which do derive, and either of which a host may log.
107///
108/// Variable *names* survive, because that is the same line the errors draw:
109/// naming `env.GITHUB_TOKEN` is what makes a misconfiguration fixable, and it
110/// repeats nothing that was read. The remote transports' headers need no help
111/// — mentra types SSE and Streamable HTTP headers alike as `SecretString`,
112/// which redacts itself — but their `url` is a plain `String`, and a query
113/// string is where an expanded credential sits (`?key=…`), so both remote
114/// arms print it through `redaction::redacted_url`.
115impl std::fmt::Debug for McpServer {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::Stdio(config) => f
119                .debug_struct("Stdio")
120                .field("name", &config.name)
121                .field("command", &config.command)
122                .field("args", &config.args)
123                .field("cwd", &config.cwd)
124                .field("env", &crate::redaction::redacted_env(config.env.keys()))
125                .finish(),
126            Self::Sse(config) => f
127                .debug_struct("Sse")
128                .field("name", &config.name)
129                .field("url", &crate::redaction::redacted_url(&config.url))
130                .field("headers", &config.headers)
131                .finish(),
132            Self::Http(config) => f
133                .debug_struct("Http")
134                .field("name", &config.name)
135                .field("url", &crate::redaction::redacted_url(&config.url))
136                .field("headers", &config.headers)
137                .finish(),
138        }
139    }
140}
141
142impl McpServer {
143    /// The server's name, which is also the namespace its tools land under.
144    pub fn name(&self) -> &str {
145        match self {
146            Self::Stdio(config) => &config.name,
147            Self::Sse(config) => &config.name,
148            Self::Http(config) => &config.name,
149        }
150    }
151
152    /// The stdio configuration, for a caller that needs the concrete type.
153    pub fn as_stdio(&self) -> Option<&McpServerConfig> {
154        match self {
155            Self::Stdio(config) => Some(config),
156            Self::Sse(_) | Self::Http(_) => None,
157        }
158    }
159
160    /// The SSE configuration, for a caller that needs the concrete type.
161    pub fn as_sse(&self) -> Option<&McpSseServerConfig> {
162        match self {
163            Self::Sse(config) => Some(config),
164            Self::Stdio(_) | Self::Http(_) => None,
165        }
166    }
167
168    /// The Streamable HTTP configuration, for a caller that needs the concrete
169    /// type.
170    pub fn as_http(&self) -> Option<&McpStreamableHttpServerConfig> {
171        match self {
172            Self::Http(config) => Some(config),
173            Self::Stdio(_) | Self::Sse(_) => None,
174        }
175    }
176
177    /// Rejects a name mentra's own tool-namespacing cannot round-trip.
178    ///
179    /// mentra encodes every bridged tool as `mcp__{server}__{tool}` and
180    /// recovers the split with `str::split_once("__")`, taking the *first*
181    /// `__` as the boundary (mentra's `parse_mcp_tool_name`,
182    /// `mcp/bridge.rs`). A server named e.g. `evil__foo` would put a second
183    /// `__` ahead of that boundary: the encoded name
184    /// `mcp__evil__foo__real_tool` parses back as server `evil`, tool
185    /// `foo__real_tool` — indistinguishable from a server actually named
186    /// `evil`. Two servers whose names differ only by where a `__` falls
187    /// would therefore collide in the roster, and on a shared runtime one
188    /// repository's server would answer under another's claimed name.
189    ///
190    /// A name ending in a single `_` is the same hole in disguise: mentra
191    /// joins it to the literal `__` in the format string, so server `evil_`
192    /// with tool `_thing` encodes to `mcp__evil____thing` — byte-identical
193    /// to server `evil` with tool `__thing`. Barring `__` inside the name
194    /// and a trailing `_` together guarantee the first `__` in the encoded
195    /// name is genuinely the separator mentra intended, which is what makes
196    /// this the whole rule rather than half of it.
197    ///
198    /// This is the boundary check basis owns for names it hands to mentra,
199    /// not a permanent stand-in for mentra validating its own encoding —
200    /// see [oops-rs/mentra#29](https://github.com/oops-rs/mentra/issues/29).
201    ///
202    /// That issue closed in mentra 0.24: `McpManager::connect`, `connect_sse`
203    /// and `connect_streamable_http` refuse the same shapes before opening a
204    /// connection, as `McpServerNameError` surfaced through each transport's
205    /// error (`mentra/src/mcp/bridge.rs:128, 161`). So this is no longer the
206    /// only line — it is the *config-side early* one, and the reason to keep
207    /// it is what it can say: a name read out of `.mcp.json` is refused with
208    /// the file that named it and the entry that did, before any server is
209    /// dialed, rather than as one connection's failure.
210    ///
211    /// mentra's rule has a third shape this one does not, the empty name. The
212    /// `.mcp.json` loader (`mcp/file.rs`) rejects that separately, so an entry
213    /// in a file keeps its file-naming refusal; a *host-supplied* server
214    /// (`with_mcp`) with an empty name is the one case now caught at connect
215    /// rather than at load.
216    pub fn validate_name(name: &str) -> Result<(), &'static str> {
217        if name.contains("__") {
218            Err(
219                "has a name containing `__`, which mentra's `mcp__{server}__{tool}` \
220                 tool-name encoding uses as the separator between server and tool",
221            )
222        } else if name.ends_with('_') {
223            Err(
224                "has a name ending in `_`, which would join mentra's `__` separator \
225                 into an earlier boundary — server `evil_` with tool `_thing` encodes \
226                 to the same `mcp__evil____thing` as server `evil` with tool `__thing`",
227            )
228        } else {
229            Ok(())
230        }
231    }
232}
233
234/// Which MCP servers a run gets: where to look for configured ones, and any
235/// the host already has in hand.
236#[derive(Debug, Clone)]
237pub struct McpConfig {
238    /// Path relative to the workspace root. Empty names no file to look for;
239    /// [`McpConfig::supplied_only`] writes it.
240    pub workspace_file: PathBuf,
241    /// The global config directory, if any. `mcp.json` inside it is used.
242    pub global_dir: Option<PathBuf>,
243    /// Servers the host supplies directly, outranking anything on disk of the
244    /// same name. This is where an ACP client's `mcpServers` arrives.
245    pub supplied: Vec<McpServer>,
246}
247
248impl Default for McpConfig {
249    fn default() -> Self {
250        Self {
251            workspace_file: PathBuf::from(DEFAULT_WORKSPACE_MCP_FILE),
252            global_dir: crate::context::default_global_dir(),
253            supplied: Vec::new(),
254        }
255    }
256}
257
258impl McpConfig {
259    /// Adds servers the host already holds, replacing any set before.
260    pub fn with_supplied(self, supplied: Vec<McpServer>) -> Self {
261        Self { supplied, ..self }
262    }
263
264    /// The host's own servers, and no file discovery at all: neither
265    /// `.mcp.json` nor the global one is read.
266    ///
267    /// What `WorkspaceBuilder::without_discovery` leaves of this config. An
268    /// ACP client's `mcpServers` arrived through
269    /// [`with_supplied`](Self::with_supplied) and still connects, because
270    /// being handed a server is not finding one.
271    pub fn supplied_only(self) -> Self {
272        Self {
273            workspace_file: PathBuf::new(),
274            global_dir: None,
275            ..self
276        }
277    }
278}
279
280/// One `.mcp.json` that exists on disk, and what it configured.
281#[derive(Debug, Clone)]
282pub struct McpSource {
283    pub path: PathBuf,
284    pub scope: ContextScope,
285    pub servers: Vec<McpServer>,
286}
287
288/// One server as configured, with what basis decided along the way kept
289/// beside what it built.
290///
291/// `pub(crate)` plumbing rather than API: its one consumer is the connect
292/// loop, which wants to say *why* a transport was chosen when the choice
293/// then fails to connect.
294pub(crate) struct ConfiguredServer {
295    pub(crate) server: McpServer,
296    /// True when no `type` named a transport and the bare-`url` rule chose
297    /// SSE — the one inference worth diagnosing when the connection fails,
298    /// because the server may simply speak Streamable HTTP.
299    pub(crate) sse_inferred: bool,
300}
301
302/// A parsed file before the inference flags are stripped for the public
303/// report.
304struct ReadSource {
305    path: PathBuf,
306    scope: ContextScope,
307    configured: Vec<ConfiguredServer>,
308}
309
310/// Anything that can go wrong turning configuration into servers.
311///
312/// These messages travel — into `basis spawn --json`, into an ACP client's error
313/// pane, into whatever a host logs. A `.mcp.json` is gitignored in most
314/// projects (basis's own included) because its `env` and `headers` hold
315/// credentials, so a message may name the file, the server, the field, and an
316/// environment variable, and nothing else it read.
317///
318/// The single deliberate exception is `type`, which is quoted back when it
319/// names a transport basis does not know. It is a keyword slot — `stdio`,
320/// `sse`, `http` — that cannot hold a credential, and repeating it is what
321/// turns the error into a fix.
322///
323/// [`Parse`](Self::Parse) is why this is a rule rather than a habit: serde's
324/// own message quotes the offending value (`invalid type: string "sk-live-…",
325/// expected a map`), so basis reports the location and the kind of problem and
326/// drops the message — including as a `source`, which `Debug` would print.
327/// Line and column are what an operator needs anyway.
328#[derive(Debug, Error)]
329#[non_exhaustive]
330pub enum McpError {
331    #[error("failed to read MCP configuration {path}: {source}")]
332    Read {
333        path: PathBuf,
334        #[source]
335        source: std::io::Error,
336    },
337
338    #[error("{path} is not valid JSON: {problem} at line {line}, column {column}")]
339    Parse {
340        path: PathBuf,
341        problem: &'static str,
342        line: usize,
343        column: usize,
344    },
345
346    #[error("{path} has no `mcpServers` object")]
347    NoServers { path: PathBuf },
348
349    #[error("{origin}: MCP server `{name}` {reason}")]
350    Invalid {
351        origin: String,
352        name: String,
353        reason: String,
354    },
355
356    #[error("{origin}: an MCP server was configured over a transport basis does not recognize")]
357    UnknownTransport { origin: String },
358}
359
360/// Every `.mcp.json` that exists, most specific first.
361///
362/// A missing file is not an error — most workspaces configure no servers. A
363/// file that exists and cannot be read or understood is, because the operator
364/// wrote it meaning something.
365pub fn discover(workspace: &Path, config: &McpConfig) -> Result<Vec<McpSource>, McpError> {
366    Ok(discovered(workspace, config)?
367        .into_iter()
368        .map(|source| McpSource {
369            path: source.path,
370            scope: source.scope,
371            servers: source
372                .configured
373                .into_iter()
374                .map(|configured| configured.server)
375                .collect(),
376        })
377        .collect())
378}
379
380/// [`discover`], with the inference flags still attached.
381fn discovered(workspace: &Path, config: &McpConfig) -> Result<Vec<ReadSource>, McpError> {
382    let mut sources = Vec::new();
383
384    if let Some(workspace_file) = crate::paths::candidate(workspace, &config.workspace_file)
385        && workspace_file.is_file()
386    {
387        sources.push(read(workspace_file, ContextScope::Workspace)?);
388    }
389
390    if let Some(global) = &config.global_dir {
391        let global_file = global.join(DEFAULT_GLOBAL_MCP_FILE);
392        // The same file reached twice is one source, not two: registering its
393        // servers again would collide with itself in the tool roster.
394        if global_file.is_file()
395            && !sources
396                .iter()
397                .any(|source| crate::paths::same_dir(&source.path, &global_file))
398        {
399            sources.push(read(global_file, ContextScope::Global)?);
400        }
401    }
402
403    Ok(sources)
404}
405
406/// Every server a run should connect, strongest source first.
407///
408/// This is what a caller almost always wants: [`discover`] reports where each
409/// server came from, and this layers those reports into the one list a runtime
410/// is built from.
411pub fn servers(workspace: &Path, config: &McpConfig) -> Result<Vec<McpServer>, McpError> {
412    Ok(configured(workspace, config)?
413        .into_iter()
414        .map(|configured| configured.server)
415        .collect())
416}
417
418/// [`servers`], with the inference flags still attached — what the workspace
419/// open hands the connect loop.
420pub(crate) fn configured(
421    workspace: &Path,
422    config: &McpConfig,
423) -> Result<Vec<ConfiguredServer>, McpError> {
424    let discovered = discovered(workspace, config)?;
425
426    Ok(layer(configured_supplied(config)?.into_iter().chain(
427        discovered.into_iter().flat_map(|source| source.configured),
428    )))
429}
430
431/// The host-supplied servers only, with no file discovery.
432pub(crate) fn configured_supplied(config: &McpConfig) -> Result<Vec<ConfiguredServer>, McpError> {
433    // `.mcp.json` and an ACP client's `mcpServers` already check their own
434    // entries at the door — see [`McpServer::validate_name`]. A Rust host's
435    // own list, set through [`crate::workspace::WorkspaceBuilder::with_mcp`],
436    // has no door of its own to check at: nothing stops it building an
437    // [`McpServer`] directly. This is the one place every source has already
438    // converged, so it is where that last gap gets closed.
439    for server in &config.supplied {
440        McpServer::validate_name(server.name()).map_err(|reason| McpError::Invalid {
441            origin: "the supplied MCP server list".to_string(),
442            name: server.name().to_string(),
443            reason: reason.to_string(),
444        })?;
445    }
446
447    Ok(layer(config.supplied.iter().cloned().map(|server| {
448        // A supplied server arrived in a typed variant; nothing about its
449        // transport was inferred.
450        ConfiguredServer {
451            server,
452            sse_inferred: false,
453        }
454    })))
455}
456
457/// Keeps the first server seen under each name.
458///
459/// Callers pass strongest-first, so "first wins" is "most specific wins".
460fn layer(servers: impl IntoIterator<Item = ConfiguredServer>) -> Vec<ConfiguredServer> {
461    let roots = servers.into_iter().enumerate().map(|(index, server)| {
462        let mut root = std::collections::BTreeMap::new();
463        root.insert(server.server.name().to_string(), (index, server));
464        Ok::<_, std::convert::Infallible>(root)
465    });
466    let mut kept = crate::named_roots::merge_roots(roots).expect("the roots are infallible");
467    // `merge_roots` is name-keyed; restore the caller's order after it chooses
468    // the strongest writer so the connect order does not change.
469    kept.sort_by_key(|(index, _)| *index);
470    kept.into_iter().map(|(_, server)| server).collect()
471}
472
473fn read(path: PathBuf, scope: ContextScope) -> Result<ReadSource, McpError> {
474    let text = std::fs::read_to_string(&path).map_err(|source| McpError::Read {
475        path: path.clone(),
476        source,
477    })?;
478
479    let configured = file::parse(&path, &text)?;
480
481    Ok(ReadSource {
482        path,
483        scope,
484        configured,
485    })
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn config(global: Option<PathBuf>) -> McpConfig {
493        McpConfig {
494            workspace_file: PathBuf::from(DEFAULT_WORKSPACE_MCP_FILE),
495            global_dir: global,
496            supplied: Vec::new(),
497        }
498    }
499
500    fn write(path: &Path, body: &str) {
501        std::fs::create_dir_all(path.parent().expect("a parent")).expect("create dir");
502        std::fs::write(path, body).expect("write file");
503    }
504
505    fn one_stdio(name: &str, command: &str) -> String {
506        format!(r#"{{"mcpServers":{{"{name}":{{"command":"{command}"}}}}}}"#)
507    }
508
509    #[test]
510    fn nothing_on_disk_means_no_servers() {
511        let tmp = tempfile::tempdir().expect("tempdir");
512
513        let found = discover(tmp.path(), &config(None)).expect("no file is not an error");
514
515        assert!(found.is_empty());
516    }
517
518    #[test]
519    fn a_workspace_file_is_found() {
520        let tmp = tempfile::tempdir().expect("tempdir");
521        write(
522            &tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
523            &one_stdio("fs", "npx"),
524        );
525
526        let found = discover(tmp.path(), &config(None)).expect("discovery succeeds");
527
528        assert_eq!(found.len(), 1);
529        assert_eq!(found[0].scope, ContextScope::Workspace);
530        assert_eq!(found[0].servers.len(), 1);
531        assert_eq!(found[0].servers[0].name(), "fs");
532    }
533
534    #[test]
535    fn the_workspace_file_outranks_the_global_one() {
536        let tmp = tempfile::tempdir().expect("tempdir");
537        let global = tmp.path().join("global");
538        write(
539            &tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
540            &one_stdio("fs", "workspace-command"),
541        );
542        write(
543            &global.join(DEFAULT_GLOBAL_MCP_FILE),
544            &one_stdio("fs", "global-command"),
545        );
546
547        let found =
548            discover(tmp.path(), &config(Some(global.clone()))).expect("discovery succeeds");
549        assert_eq!(found.len(), 2);
550        assert_eq!(found[0].scope, ContextScope::Workspace);
551        assert_eq!(found[1].scope, ContextScope::Global);
552
553        let layered = servers(tmp.path(), &config(Some(global))).expect("layering succeeds");
554        assert_eq!(layered.len(), 1, "one name is one server");
555        assert_eq!(
556            layered[0].as_stdio().expect("stdio").command,
557            "workspace-command"
558        );
559    }
560
561    #[test]
562    fn a_global_server_survives_alongside_a_workspace_one() {
563        let tmp = tempfile::tempdir().expect("tempdir");
564        let global = tmp.path().join("global");
565        write(
566            &tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
567            &one_stdio("local", "a"),
568        );
569        write(
570            &global.join(DEFAULT_GLOBAL_MCP_FILE),
571            &one_stdio("shared", "b"),
572        );
573
574        let layered = servers(tmp.path(), &config(Some(global))).expect("layering succeeds");
575
576        let names: Vec<&str> = layered.iter().map(McpServer::name).collect();
577        assert_eq!(names, vec!["local", "shared"]);
578    }
579
580    #[test]
581    fn a_supplied_server_outranks_both_files() {
582        let tmp = tempfile::tempdir().expect("tempdir");
583        write(
584            &tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
585            &one_stdio("fs", "from-the-file"),
586        );
587
588        let config = McpConfig {
589            supplied: vec![McpServer::Stdio(McpServerConfig {
590                name: "fs".to_string(),
591                command: "from-the-client".to_string(),
592                args: Vec::new(),
593                env: Default::default(),
594                cwd: None,
595            })],
596            ..config(None)
597        };
598
599        let layered = servers(tmp.path(), &config).expect("layering succeeds");
600
601        assert_eq!(layered.len(), 1);
602        assert_eq!(
603            layered[0].as_stdio().expect("stdio").command,
604            "from-the-client",
605            "the client is answering for this session in particular"
606        );
607    }
608
609    #[test]
610    fn a_supplied_server_with_a_double_underscore_name_is_rejected() {
611        // Nothing stops a Rust host building an `McpServer` directly and
612        // handing it to `WorkspaceBuilder::with_mcp` — this is the one
613        // producer `.mcp.json` and an ACP client's `mcpServers` cannot check
614        // at their own door, so `configured` is where the check lands for
615        // this source instead. See [`McpServer::validate_name`].
616        let tmp = tempfile::tempdir().expect("tempdir");
617
618        let config = McpConfig {
619            supplied: vec![McpServer::Stdio(McpServerConfig {
620                name: "evil__foo".to_string(),
621                command: "from-the-host".to_string(),
622                args: Vec::new(),
623                env: Default::default(),
624                cwd: None,
625            })],
626            ..config(None)
627        };
628
629        let error =
630            servers(tmp.path(), &config).expect_err("mentra's split would misparse this name");
631
632        assert!(matches!(error, McpError::Invalid { .. }), "{error}");
633        assert!(error.to_string().contains("tool-name encoding"), "{error}");
634    }
635
636    #[test]
637    fn a_supplied_server_with_a_trailing_underscore_name_is_rejected() {
638        // `evil_` doesn't contain `__` itself, but a tool named `_thing`
639        // joins onto it: `mcp__evil____thing` is byte-identical to server
640        // `evil` with tool `__thing`. See [`McpServer::validate_name`].
641        let tmp = tempfile::tempdir().expect("tempdir");
642
643        let config = McpConfig {
644            supplied: vec![McpServer::Stdio(McpServerConfig {
645                name: "evil_".to_string(),
646                command: "from-the-host".to_string(),
647                args: Vec::new(),
648                env: Default::default(),
649                cwd: None,
650            })],
651            ..config(None)
652        };
653
654        let error = servers(tmp.path(), &config)
655            .expect_err("a trailing `_` can join a tool's leading `_` into a fake separator");
656
657        assert!(matches!(error, McpError::Invalid { .. }), "{error}");
658        assert!(error.to_string().contains("ending in `_`"), "{error}");
659    }
660
661    #[test]
662    fn the_same_file_reached_twice_is_read_once() {
663        let tmp = tempfile::tempdir().expect("tempdir");
664        let global = tmp.path().join("global");
665        write(
666            &global.join(DEFAULT_GLOBAL_MCP_FILE),
667            &one_stdio("fs", "npx"),
668        );
669
670        // Point the workspace file at the very same place.
671        let found = discover(
672            &global,
673            &McpConfig {
674                workspace_file: PathBuf::from(DEFAULT_GLOBAL_MCP_FILE),
675                global_dir: Some(global.clone()),
676                supplied: Vec::new(),
677            },
678        )
679        .expect("discovery succeeds");
680
681        assert_eq!(found.len(), 1);
682        assert_eq!(found[0].scope, ContextScope::Workspace);
683    }
684
685    #[test]
686    fn a_directory_where_the_file_should_be_is_ignored() {
687        let tmp = tempfile::tempdir().expect("tempdir");
688        std::fs::create_dir_all(tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE))
689            .expect("create a directory with the file's name");
690
691        let found = discover(tmp.path(), &config(None)).expect("discovery succeeds");
692
693        assert!(found.is_empty());
694    }
695
696    #[test]
697    fn a_malformed_file_is_an_error_not_a_silent_skip() {
698        let tmp = tempfile::tempdir().expect("tempdir");
699        write(&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE), "{not json");
700
701        let error = discover(tmp.path(), &config(None)).expect_err("malformed is an error");
702
703        assert!(matches!(error, McpError::Parse { .. }), "{error}");
704    }
705
706    #[test]
707    fn defaults_configure_no_servers_of_their_own() {
708        let config = McpConfig::default();
709
710        assert_eq!(config.workspace_file, PathBuf::from(".mcp.json"));
711        assert!(config.supplied.is_empty());
712    }
713
714    #[test]
715    fn supplying_servers_returns_a_new_config() {
716        let base = McpConfig::default();
717        let supplied = base
718            .clone()
719            .with_supplied(vec![McpServer::Sse(McpSseServerConfig::new(
720                "obs",
721                "https://example.com/sse",
722            ))]);
723
724        assert!(base.supplied.is_empty(), "the original is untouched");
725        assert_eq!(supplied.supplied.len(), 1);
726    }
727
728    #[test]
729    fn a_servers_environment_is_not_printed() {
730        // By the time a server is one of these, `${GITHUB_TOKEN}` has been
731        // expanded — so this is the real value, not the placeholder.
732        let server = McpServer::Stdio(McpServerConfig {
733            name: "gh".to_string(),
734            command: "server".to_string(),
735            args: vec!["--org".to_string(), "acme".to_string()],
736            env: [("GITHUB_TOKEN".to_string(), "ghp-secret-value".to_string())]
737                .into_iter()
738                .collect(),
739            cwd: None,
740        });
741
742        let printed = format!("{server:?}");
743
744        assert!(!printed.contains("ghp-secret-value"));
745        assert!(printed.contains("redacted"));
746        assert!(
747            printed.contains("GITHUB_TOKEN"),
748            "the variable's name is what makes a misconfiguration fixable"
749        );
750        assert!(
751            printed.contains("server") && printed.contains("acme"),
752            "the command and its arguments are how a spawn is debugged"
753        );
754    }
755
756    #[test]
757    fn a_configured_server_is_not_printed_by_whatever_holds_it() {
758        // `McpConfig` derives `Debug`, and other configs hold one — so the
759        // redaction has to survive being nested rather than being something a
760        // caller has to remember to reach for.
761        let config = McpConfig::default().with_supplied(vec![McpServer::Stdio(McpServerConfig {
762            name: "gh".to_string(),
763            command: "server".to_string(),
764            args: Vec::new(),
765            env: [("GITHUB_TOKEN".to_string(), "ghp-secret-value".to_string())]
766                .into_iter()
767                .collect(),
768            cwd: None,
769        })]);
770
771        assert!(!format!("{config:?}").contains("ghp-secret-value"));
772    }
773}