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}