返回 CodeWhale
mcp.rs
根目录 / crates / tui / src / mcp.rs
1 //! Async MCP (Model Context Protocol) Implementation
2 //!
3 //! This module provides full async support for MCP servers with:
4 //! - Connection pooling for server reuse
5 //! - Automatic tool discovery via `tools/list`
6 //! - Configurable timeouts per-server and globally
7
8 use std::collections::{HashMap, HashSet};
9 use std::ffi::{OsStr, OsString};
10 use std::fs;
11 use std::future::Future;
12 use std::io::{Read, Seek};
13 use std::path::{Component, Path, PathBuf};
14 use std::sync::Arc;
15 use std::sync::atomic::{AtomicU64, Ordering};
16 use std::time::Duration;
17
18 use anyhow::{Context, Result};
19 use parking_lot::RwLock;
20 use serde::{Deserialize, Serialize};
21 use sha2::Digest as _;
22
23 pub mod external_import;
24 mod headers;
25 pub mod oauth;
26 mod sse;
27 mod stdio;
28 mod streamable_http;
29
30 use self::headers::{apply_safe_custom_headers, with_default_mcp_http_headers};
31 use self::sse::SseTransport;
32 use self::stdio::StdioTransport;
33 #[cfg(all(test, unix))]
34 use self::stdio::{STDIO_SHUTDOWN_GRACE, StderrTail};
35 use self::streamable_http::{StreamableHttpTransport, StreamableSendError};
36 use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};
37 use crate::utils::write_atomic;
38
39 // === Error diagnostics helpers (#71) ===
40
41 /// Bytes of a non-2xx response body to surface in connection errors.
42 const ERROR_BODY_PREVIEW_BYTES: usize = 200;
43
44 fn validate_mcp_config_path(path: &Path) -> Result<()> {
45 if path.as_os_str().is_empty() {
46 anyhow::bail!("MCP config path cannot be empty");
47 }
48 if path
49 .components()
50 .any(|component| matches!(component, Component::ParentDir))
51 {
52 anyhow::bail!("MCP config path cannot contain '..' components");
53 }
54 Ok(())
55 }
56
57 /// Expand `${NAME}` placeholders in an MCP config value from the process
58 /// environment. This lets secrets (API keys, bearer tokens, …) be supplied
59 /// through environment variables instead of being written in cleartext into
60 /// the MCP config file on disk.
61 ///
62 /// On a missing or malformed placeholder the error names only the offending
63 /// variable, never the surrounding value, so a secret-bearing string is never
64 /// echoed into logs or error output.
65 fn expand_env_placeholders_with(
66 value: &str,
67 environment: Option<&crate::plugins::HostEnvironment>,
68 ) -> Result<String> {
69 let mut out = String::new();
70 let mut rest = value;
71 while let Some(start) = rest.find("${") {
72 out.push_str(&rest[..start]);
73 let after = &rest[start + 2..];
74 let Some(end) = after.find('}') else {
75 anyhow::bail!("unterminated environment placeholder in MCP config value");
76 };
77 let name = &after[..end];
78 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
79 anyhow::bail!("invalid environment placeholder in MCP config value");
80 }
81 let env_value = environment
82 .map_or_else(|| std::env::var(name), |env| env.var(name))
83 .with_context(|| {
84 format!("environment variable {name} required by MCP config is not set")
85 })?;
86 out.push_str(&env_value);
87 rest = &after[end + 1..];
88 }
89 out.push_str(rest);
90 Ok(out)
91 }
92
93 #[cfg(test)]
94 fn expand_env_placeholders(value: &str) -> Result<String> {
95 expand_env_placeholders_with(value, None)
96 }
97
98 /// Expand `${NAME}` placeholders across every value of an MCP config map
99 /// (e.g. the stdio child `env`). `context` only labels expansion errors so a
100 /// failure can be attributed to the right map.
101 fn expand_env_placeholders_map_with_environment(
102 values: &HashMap<String, String>,
103 context: &str,
104 environment: Option<&crate::plugins::HostEnvironment>,
105 ) -> Result<HashMap<String, String>> {
106 let mut expanded = HashMap::with_capacity(values.len());
107 for (key, value) in values {
108 expanded.insert(
109 key.clone(),
110 expand_env_placeholders_with(value, environment)
111 .with_context(|| format!("failed to expand MCP {context} value for {key}"))?,
112 );
113 }
114 Ok(expanded)
115 }
116
117 #[cfg(test)]
118 fn expand_env_placeholders_map(
119 values: &HashMap<String, String>,
120 context: &str,
121 ) -> Result<HashMap<String, String>> {
122 expand_env_placeholders_map_with_environment(values, context, None)
123 }
124
125 fn expanded_mcp_stdio_env(config: &McpServerConfig) -> Result<HashMap<String, String>> {
126 let environment = config
127 .reviewed_plugin
128 .as_ref()
129 .map(|source| source.host_environment.as_ref());
130 expand_env_placeholders_map_with_environment(&config.env, "env", environment)
131 }
132
133 /// Mirror the exact expanded and sanitized environment applied by the MCP
134 /// stdio spawn path, without constructing or starting a process.
135 fn mcp_stdio_child_env(config: &McpServerConfig) -> Result<Vec<(OsString, OsString)>> {
136 let expanded_env = expanded_mcp_stdio_env(config)?;
137 let overrides = crate::child_env::string_map_env(&expanded_env);
138 Ok(if let Some(source) = config.reviewed_plugin.as_ref() {
139 // Plugin reviews name every extra environment source explicitly. Do
140 // not silently widen that consent to the compatibility-oriented MCP
141 // bootstrap namespace (for example NPM_CONFIG_*).
142 crate::child_env::sanitized_plugin_mcp_env_from(
143 source.host_environment.entries().iter().cloned(),
144 overrides,
145 )
146 } else {
147 crate::child_env::sanitized_mcp_env(overrides)
148 })
149 }
150
151 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
152 pub(crate) enum McpCommandAvailability {
153 Available,
154 Missing,
155 NotApplicable,
156 NotChecked,
157 }
158
159 impl McpCommandAvailability {
160 pub(crate) fn as_str(self) -> &'static str {
161 match self {
162 Self::Available => "available",
163 Self::Missing => "missing",
164 Self::NotApplicable => "not_applicable",
165 Self::NotChecked => "not_checked",
166 }
167 }
168 }
169
170 pub(crate) fn is_relative_stdio_path_arg(value: &str) -> bool {
171 if value.is_empty() || value.starts_with('-') || value.contains("://") || value.starts_with('~')
172 {
173 return false;
174 }
175 let looks_like_path = value.contains('/') || value.contains('\\');
176 if !looks_like_path {
177 return false;
178 }
179 let bytes = value.as_bytes();
180 let windows_absolute = value.starts_with("\\\\")
181 || (bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/'));
182 !Path::new(value).is_absolute() && !windows_absolute
183 }
184
185 fn env_value<'a>(env: &'a [(OsString, OsString)], name: &str) -> Option<&'a OsStr> {
186 env.iter()
187 .rev()
188 .find(|(key, _)| {
189 #[cfg(windows)]
190 {
191 key.to_string_lossy().eq_ignore_ascii_case(name)
192 }
193 #[cfg(not(windows))]
194 {
195 key == OsStr::new(name)
196 }
197 })
198 .map(|(_, value)| value.as_os_str())
199 }
200
201 #[cfg(unix)]
202 fn spawnable_command_file(path: &Path) -> bool {
203 use std::os::unix::fs::PermissionsExt;
204
205 path.is_file()
206 && fs::metadata(path)
207 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
208 .unwrap_or(false)
209 }
210
211 #[cfg(windows)]
212 fn spawnable_command_file(path: &Path) -> bool {
213 path.is_file() || (path.extension().is_none() && path.with_extension("exe").is_file())
214 }
215
216 #[cfg(not(any(unix, windows)))]
217 fn spawnable_command_file(path: &Path) -> bool {
218 path.is_file()
219 }
220
221 fn path_candidate(dir: &Path, name: &str, cwd: Option<&Path>) -> PathBuf {
222 #[cfg(unix)]
223 {
224 // Unix performs PATH lookup after applying Command::current_dir. That
225 // includes empty PATH entries, which mean the child's current dir.
226 if dir.is_relative()
227 && let Some(cwd) = cwd
228 {
229 return cwd.join(dir).join(name);
230 }
231 }
232 #[cfg(not(unix))]
233 let _ = cwd;
234 dir.join(name)
235 }
236
237 fn command_availability_on_path(
238 name: &str,
239 env: &[(OsString, OsString)],
240 cwd: Option<&Path>,
241 ) -> McpCommandAvailability {
242 let Some(path) = env_value(env, "PATH") else {
243 // On Unix execvp falls back to an OS-defined path. On Windows Rust's
244 // resolver still checks system and parent locations. We cannot prove a
245 // miss without reproducing platform internals, so remain conservative.
246 return McpCommandAvailability::NotChecked;
247 };
248 for dir in std::env::split_paths(path) {
249 let candidate = path_candidate(&dir, name, cwd);
250 if spawnable_command_file(&candidate) {
251 return McpCommandAvailability::Available;
252 }
253 }
254
255 #[cfg(windows)]
256 {
257 // Windows Command resolution also checks the running executable's
258 // directory, system directories, and the parent PATH after an explicit
259 // child PATH. A static miss in the child PATH is therefore not proof
260 // that spawn will fail. PATHEXT is intentionally not consulted: Rust
261 // only supplies an omitted `.exe`; `.cmd`/`.bat` must be explicit.
262 return McpCommandAvailability::NotChecked;
263 }
264 #[cfg(not(windows))]
265 {
266 McpCommandAvailability::Missing
267 }
268 }
269
270 /// Inspect an MCP stdio command using the same expanded, sanitized environment
271 /// as the real spawn path, without starting the configured process.
272 pub(crate) fn static_mcp_command_availability(
273 server: &McpServerConfig,
274 ) -> Result<McpCommandAvailability> {
275 if server.url.is_some() {
276 return Ok(McpCommandAvailability::NotApplicable);
277 }
278 let Some(cmd) = server.command.as_deref() else {
279 return Ok(McpCommandAvailability::NotChecked);
280 };
281 if cmd.is_empty() {
282 return Ok(McpCommandAvailability::Missing);
283 }
284
285 // StdioTransport expands every configured env value before spawning, even
286 // when the command itself is absolute. Mirror that failure boundary here.
287 let child_env = mcp_stdio_child_env(server)?;
288 let path = Path::new(cmd);
289 let is_absolute = path.is_absolute() || cmd.starts_with('/');
290 if is_absolute {
291 return Ok(if spawnable_command_file(path) {
292 McpCommandAvailability::Available
293 } else {
294 McpCommandAvailability::Missing
295 });
296 }
297
298 if is_relative_stdio_path_arg(cmd) {
299 let Some(cwd) = server.cwd.as_deref() else {
300 return Ok(McpCommandAvailability::NotChecked);
301 };
302 return Ok(if spawnable_command_file(&cwd.join(path)) {
303 McpCommandAvailability::Available
304 } else {
305 McpCommandAvailability::Missing
306 });
307 }
308
309 Ok(command_availability_on_path(
310 cmd,
311 &child_env,
312 server.cwd.as_deref(),
313 ))
314 }
315
316 /// Mask a URL so any embedded credentials in the userinfo portion (e.g.
317 /// `https://user:secret@host`) are replaced with `***`. Failures fall back to
318 /// the original string so we don't lose context — we never want masking to
319 /// produce an empty error.
320 fn mask_url_secrets(url: &str) -> String {
321 if let Ok(parsed) = reqwest::Url::parse(url) {
322 let mut clone = parsed.clone();
323 if !parsed.username().is_empty() || parsed.password().is_some() {
324 let _ = clone.set_username("***");
325 let _ = clone.set_password(Some("***"));
326 }
327 if parsed.query().is_some() {
328 clone.set_query(Some("***"));
329 }
330 clone.set_fragment(None);
331 return clone.to_string();
332 }
333 url.to_string()
334 }
335
336 /// Redact the userinfo segment (`username[:password]@…` portion) from
337 /// a proxy URL so it can be safely included in `tracing::warn!` output
338 /// without leaking the
339 /// password into the on-disk log. URLs without userinfo are returned
340 /// unchanged. Garbage input (no `://` scheme separator) is also returned
341 /// unchanged — the malformed-URL warning path is the only caller, so an
342 /// unparseable input is already the failure case.
343 fn redact_proxy_userinfo(proxy_url: &str) -> String {
344 let Some(scheme_end) = proxy_url.find("://") else {
345 return proxy_url.to_string();
346 };
347 let after_scheme = scheme_end + 3;
348 // The userinfo segment ends at the next `@`, but only if that `@`
349 // comes before the next `/`, `?`, or `#` (otherwise the `@` is in a
350 // path / query and the URL has no userinfo at all).
351 let rest = &proxy_url[after_scheme..];
352 let at_idx = rest.find('@');
353 let path_idx = rest.find(['/', '?', '#']);
354 let userinfo_end = match (at_idx, path_idx) {
355 (Some(a), Some(p)) if a < p => Some(a),
356 (Some(a), None) => Some(a),
357 _ => None,
358 };
359 if let Some(end) = userinfo_end {
360 let mut out = String::with_capacity(proxy_url.len());
361 out.push_str(&proxy_url[..after_scheme]);
362 out.push_str("***@");
363 out.push_str(&rest[end + 1..]);
364 out
365 } else {
366 proxy_url.to_string()
367 }
368 }
369
370 fn redact_values_after_ascii_needle(
371 output: &mut String,
372 needle: &str,
373 terminates: impl Fn(char) -> bool,
374 ) {
375 let needle = needle.as_bytes();
376 let mut search_from = 0_usize;
377 while search_from.saturating_add(needle.len()) <= output.len() {
378 let Some(relative) = output.as_bytes()[search_from..]
379 .windows(needle.len())
380 .position(|candidate| candidate.eq_ignore_ascii_case(needle))
381 else {
382 break;
383 };
384 let value_start = search_from + relative + needle.len();
385 let value_end = output[value_start..]
386 .char_indices()
387 .find(|(_, ch)| terminates(*ch))
388 .map_or(output.len(), |(offset, _)| value_start + offset);
389 if value_end == value_start {
390 if value_start == output.len() {
391 break;
392 }
393 // The empty value is already safe. Advance over its ASCII
394 // separator so a second occurrence later in the body is found.
395 search_from = value_start + 1;
396 continue;
397 }
398 output.replace_range(value_start..value_end, "***");
399 search_from = value_start + 3;
400 }
401 }
402
403 /// Mask obvious token-like substrings in a body excerpt before surfacing it.
404 /// Every occurrence is replaced, not only the first one.
405 fn redact_body_preview(body: &str) -> String {
406 let mut out = body.to_string();
407 redact_values_after_ascii_needle(&mut out, "bearer ", |ch| {
408 ch.is_whitespace() || ch == '"' || ch == ','
409 });
410 for needle in ["api_key=", "apikey=", "api-key=", "token="] {
411 redact_values_after_ascii_needle(&mut out, needle, |ch| {
412 ch.is_whitespace() || ch == '&' || ch == '"' || ch == ','
413 });
414 }
415 out
416 }
417
418 /// Read at most `max_bytes` of a reqwest response body and produce a
419 /// single-line excerpt suitable for an error message. The stream is dropped as
420 /// soon as the cap is reached, so an unbounded or never-ending error response
421 /// cannot make diagnostics retain the entire body. Best-effort — if the body
422 /// can't be read, returns the literal string `<no body>`.
423 async fn bounded_body_excerpt(response: reqwest::Response, max_bytes: usize) -> String {
424 use futures_util::StreamExt;
425
426 let declared_truncated = response
427 .content_length()
428 .is_some_and(|length| length > max_bytes as u64);
429 let mut stream = response.bytes_stream();
430 let mut body = Vec::with_capacity(max_bytes.min(8 * 1024));
431 let mut truncated = declared_truncated;
432
433 while body.len() < max_bytes {
434 let Some(chunk) = stream.next().await else {
435 break;
436 };
437 let Ok(chunk) = chunk else {
438 break;
439 };
440 let remaining = max_bytes - body.len();
441 if chunk.len() > remaining {
442 body.extend_from_slice(&chunk[..remaining]);
443 truncated = true;
444 break;
445 }
446 body.extend_from_slice(&chunk);
447 if body.len() == max_bytes {
448 // For a chunked response there is no length that proves EOF. Stop
449 // now rather than polling an attacker-controlled stream again.
450 truncated = true;
451 }
452 }
453
454 if body.is_empty() {
455 return "<no body>".to_string();
456 }
457
458 let one_line = String::from_utf8_lossy(&body).replace(['\n', '\r'], " ");
459 let suffix = if truncated { "…" } else { "" };
460 format!("{}{}", redact_body_preview(&one_line), suffix)
461 }
462
463 fn invalid_json_preview(bytes: &[u8]) -> String {
464 let body_text = String::from_utf8_lossy(bytes);
465 if body_text.is_empty() {
466 return "<empty>".to_string();
467 }
468
469 let trimmed: String = body_text.chars().take(ERROR_BODY_PREVIEW_BYTES).collect();
470 let suffix = if body_text.chars().count() > ERROR_BODY_PREVIEW_BYTES {
471 "…"
472 } else {
473 ""
474 };
475 let one_line = trimmed.replace(['\n', '\r'], " ");
476 format!("{}{}", redact_body_preview(&one_line), suffix)
477 }
478
479 // === Configuration Types ===
480
481 /// Full MCP configuration from mcp.json
482 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
483 pub struct McpConfig {
484 #[serde(default)]
485 pub timeouts: McpTimeouts,
486 #[serde(default, alias = "mcpServers")]
487 pub servers: HashMap<String, McpServerConfig>,
488 }
489
490 /// Global timeout configuration
491 #[derive(Debug, Clone, Copy, Deserialize, Serialize)]
492 #[allow(clippy::struct_field_names)]
493 pub struct McpTimeouts {
494 #[serde(default = "default_connect_timeout")]
495 pub connect_timeout: u64,
496 #[serde(default = "default_execute_timeout")]
497 pub execute_timeout: u64,
498 #[serde(default = "default_read_timeout")]
499 pub read_timeout: u64,
500 }
501
502 fn default_connect_timeout() -> u64 {
503 10
504 }
505 fn default_execute_timeout() -> u64 {
506 60
507 }
508 fn default_read_timeout() -> u64 {
509 120
510 }
511
512 impl Default for McpTimeouts {
513 fn default() -> Self {
514 Self {
515 connect_timeout: default_connect_timeout(),
516 execute_timeout: default_execute_timeout(),
517 read_timeout: default_read_timeout(),
518 }
519 }
520 }
521
522 /// Configuration for a single MCP server
523 #[derive(Debug, Clone, Deserialize, Serialize)]
524 pub struct McpServerConfig {
525 pub command: Option<String>,
526 #[serde(default)]
527 pub args: Vec<String>,
528 #[serde(default)]
529 pub env: HashMap<String, String>,
530 #[serde(default)]
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub cwd: Option<PathBuf>,
533 pub url: Option<String>,
534 /// Optional explicit HTTP transport override.
535 ///
536 /// By default URL-based MCP servers use Streamable HTTP first and fall
537 /// back to legacy SSE only when the server rejects Streamable HTTP with
538 /// a known incompatible status. Set this to `"sse"` for legacy SSE
539 /// endpoints that must start with a long-lived GET endpoint discovery
540 /// stream and cannot accept an initial POST to the configured URL.
541 #[serde(default)]
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub transport: Option<String>,
544 #[serde(default)]
545 pub connect_timeout: Option<u64>,
546 #[serde(default)]
547 pub execute_timeout: Option<u64>,
548 #[serde(default)]
549 pub read_timeout: Option<u64>,
550 #[serde(default)]
551 pub disabled: bool,
552 #[serde(default = "default_enabled")]
553 pub enabled: bool,
554 #[serde(default)]
555 pub required: bool,
556 #[serde(default)]
557 pub enabled_tools: Vec<String>,
558 #[serde(default)]
559 pub disabled_tools: Vec<String>,
560 /// Extra HTTP headers sent with every request to this MCP server.
561 /// Only the HTTP transports (streamable HTTP today; SSE in a
562 /// follow-up) honor this — `command`-based stdio servers ignore it.
563 ///
564 /// Mirrors the `headers` field that Claude Code, Codex, and
565 /// OpenCode already accept in their MCP config formats. Use it to
566 /// authenticate against gateways that require a Bearer token or
567 /// API key, e.g.:
568 ///
569 /// ```jsonc
570 /// "huggingface": {
571 /// "url": "https://huggingface.co/api/mcp",
572 /// "headers": { "Authorization": "Bearer ${HF_TOKEN}" }
573 /// }
574 /// ```
575 ///
576 /// Header keys and values are passed through as-is — we do not
577 /// substitute environment variables in v0.8.31. If you store a
578 /// real token here, the value lives in plain text in
579 /// `~/.deepseek/mcp.json`; treat that file with the same care
580 /// as any other secret-bearing config.
581 #[serde(default)]
582 #[serde(skip_serializing_if = "HashMap::is_empty")]
583 pub headers: HashMap<String, String>,
584 /// HTTP headers whose values are read from environment variables at request
585 /// time. This keeps common bearer/API-token integrations out of mcp.json.
586 #[serde(default, alias = "env_http_headers")]
587 #[serde(skip_serializing_if = "HashMap::is_empty")]
588 pub env_headers: HashMap<String, String>,
589 /// Environment variable containing a bearer token. When present and set,
590 /// CodeWhale sends `Authorization: Bearer <value>` for URL-based servers.
591 #[serde(default)]
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub bearer_token_env_var: Option<String>,
594 /// OAuth scopes requested during `codewhale mcp login`.
595 #[serde(default)]
596 #[serde(skip_serializing_if = "Vec::is_empty")]
597 pub scopes: Vec<String>,
598 /// OAuth client override for MCP servers that require a pre-registered
599 /// public client instead of dynamic registration.
600 #[serde(default)]
601 #[serde(skip_serializing_if = "Option::is_none")]
602 pub oauth: Option<McpServerOAuthConfig>,
603 /// Optional RFC 8707 resource parameter appended to the authorization URL.
604 #[serde(default)]
605 #[serde(skip_serializing_if = "Option::is_none")]
606 pub oauth_resource: Option<String>,
607 /// In-memory provenance for MCP servers contributed by a reviewed plugin
608 /// bundle. This is never deserialized from or serialized into user config:
609 /// only the trusted plugin merge adapter may attach it.
610 #[serde(skip)]
611 pub(crate) reviewed_plugin: Option<ReviewedPluginMcpSource>,
612 }
613
614 #[derive(Debug, Clone)]
615 pub(crate) struct ReviewedPluginMcpSource {
616 authority: crate::plugins::types::PluginAuthority,
617 approved_remote_endpoint: Option<String>,
618 approved_remote_origin: Option<String>,
619 host_environment: Arc<crate::plugins::HostEnvironment>,
620 }
621
622 impl ReviewedPluginMcpSource {
623 fn from_authority(
624 authority: crate::plugins::types::PluginAuthority,
625 remote_endpoint: Option<&str>,
626 host_environment: Arc<crate::plugins::HostEnvironment>,
627 ) -> Result<Self> {
628 let (approved_remote_endpoint, approved_remote_origin) = match remote_endpoint {
629 Some(endpoint) => reviewed_remote_endpoint_identity(endpoint)
630 .map(|(endpoint, origin)| (Some(endpoint), Some(origin)))?,
631 None => (None, None),
632 };
633 Ok(Self {
634 authority,
635 approved_remote_endpoint,
636 approved_remote_origin,
637 host_environment,
638 })
639 }
640
641 pub(crate) fn validate_before_stdio_spawn(&self, server_name: &str) -> Result<()> {
642 self.validate_before_use(server_name, "spawn")
643 }
644
645 pub(crate) fn prepare_stdio_launch(
646 &self,
647 server_name: &str,
648 command: &str,
649 args: &[String],
650 cwd: Option<&Path>,
651 ) -> Result<ReviewedStdioLaunch> {
652 self.validate_before_stdio_spawn(server_name)?;
653 let staged_root = self
654 .authority
655 .staged_manifest
656 .parent()
657 .context("reviewed plugin stage manifest has no parent")?;
658 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
659 &self.authority.staged_manifest,
660 )
661 .map_err(|_| anyhow::anyhow!("reviewed plugin stage could not be opened for launch"))?;
662 if validated.content_hash != self.authority.content_hash
663 || validated.capability_hash != self.authority.capability_hash
664 {
665 anyhow::bail!("reviewed plugin stage changed before stdio launch");
666 }
667
668 let mut launch = ReviewedStdioLaunch {
669 command: std::ffi::OsString::from(command),
670 args: args.iter().map(std::ffi::OsString::from).collect(),
671 cwd: cwd.map(Path::to_path_buf),
672 opened_files: Vec::new(),
673 #[cfg(unix)]
674 cwd_fd: None,
675 };
676 if Path::new(command).is_absolute() {
677 launch.bind_command(staged_root, Path::new(command), &validated.file_hashes)?;
678 }
679 for (index, argument) in args.iter().enumerate() {
680 let path = Path::new(argument);
681 if path.is_absolute() && path.starts_with(staged_root) && path.is_file() {
682 launch.args[index] = launch.bind_file(staged_root, path, &validated.file_hashes)?;
683 }
684 }
685 if let Some(cwd) = cwd {
686 if !cwd.starts_with(staged_root) {
687 anyhow::bail!("reviewed plugin stdio cwd escaped its staged root");
688 }
689 launch.bind_cwd(cwd)?;
690 }
691 // A final authority pass detects any non-executed companion/config
692 // drift while handles were opened. Execution itself uses the handles.
693 self.validate_before_stdio_spawn(server_name)?;
694 Ok(launch)
695 }
696
697 fn validate_before_use(&self, server_name: &str, operation: &str) -> Result<()> {
698 let remediation = format!(
699 "Run `/plugin reload`, inspect `/plugin show {0}`, then repeat the displayed trust command and `/plugin enable {0}` before retrying",
700 self.authority.plugin_name
701 );
702 crate::plugins::registry::verify_plugin_authority(&self.authority).map_err(|reason| {
703 anyhow::anyhow!(
704 "Refusing to {operation} MCP server '{server_name}' from plugin bundle `{}`: {reason}. {remediation}",
705 self.authority.plugin_name
706 )
707 })
708 }
709
710 fn validate_remote_endpoint(&self, server_name: &str, endpoint: &str) -> Result<()> {
711 let (endpoint, origin) = reviewed_remote_endpoint_identity(endpoint)?;
712 if self.approved_remote_endpoint.as_deref() != Some(endpoint.as_str())
713 || self.approved_remote_origin.as_deref() != Some(origin.as_str())
714 {
715 anyhow::bail!(
716 "Refusing MCP server '{server_name}': its remote endpoint no longer matches the reviewed plugin origin"
717 );
718 }
719 Ok(())
720 }
721
722 fn catalog_is_current(&self) -> bool {
723 // Catalog exposure is an authority boundary too: stale tool, prompt,
724 // or resource descriptions can steer the model even when the later
725 // operation would be denied. Revalidate both the mutable reviewed
726 // source and the Codewhale-owned stage before publishing any entry.
727 crate::plugins::registry::verify_plugin_authority(&self.authority).is_ok()
728 }
729 }
730
731 pub(crate) struct ReviewedStdioLaunch {
732 pub(crate) command: std::ffi::OsString,
733 pub(crate) args: Vec<std::ffi::OsString>,
734 pub(crate) cwd: Option<PathBuf>,
735 /// Kept for the child lifetime. Windows opens deny write/delete sharing;
736 /// Unix children execute/read inherited descriptors rather than paths.
737 pub(crate) opened_files: Vec<fs::File>,
738 #[cfg(unix)]
739 pub(crate) cwd_fd: Option<fs::File>,
740 }
741
742 impl ReviewedStdioLaunch {
743 fn bind_command(
744 &mut self,
745 staged_root: &Path,
746 path: &Path,
747 expected_hashes: &std::collections::BTreeMap<PathBuf, String>,
748 ) -> Result<()> {
749 let bound_path = self.bind_file(staged_root, path, expected_hashes)?;
750 #[cfg(not(target_os = "macos"))]
751 {
752 self.command = bound_path;
753 Ok(())
754 }
755 #[cfg(target_os = "macos")]
756 {
757 use std::os::unix::fs::FileExt as _;
758
759 // Darwin devfs deliberately rejects execve("/dev/fd/N"). Bind
760 // reviewed scripts by running the interpreter declared in their
761 // exact hashed shebang and passing the inherited descriptor as
762 // input. Native Mach-O bundle commands have no fexecve/execveat
763 // equivalent on Darwin, so fail closed and require the manifest
764 // to name a bare interpreter with the bundle file as an argument.
765 let file = self
766 .opened_files
767 .last()
768 .context("reviewed command handle disappeared")?;
769 let mut prefix = [0_u8; 4_096];
770 let read = file
771 .read_at(&mut prefix, 0)
772 .context("read reviewed command shebang")?;
773 let prefix = &prefix[..read];
774 let line_end = prefix
775 .iter()
776 .position(|byte| *byte == b'\n')
777 .unwrap_or(prefix.len());
778 let line = std::str::from_utf8(&prefix[..line_end])
779 .context("reviewed script shebang is not UTF-8")?;
780 let shebang = line.strip_prefix("#!").map(str::trim).filter(|s| !s.is_empty())
781 .context(
782 "Darwin cannot execute a reviewed native bundle command by descriptor; use a shebang script or declare a bare interpreter command plus the script argument",
783 )?;
784 let mut words = shlex::split(shebang)
785 .context("reviewed script shebang could not be parsed safely")?;
786 let interpreter = words
787 .first()
788 .filter(|word| Path::new(word).is_absolute())
789 .context("reviewed script shebang interpreter must be absolute")?
790 .clone();
791 words.remove(0);
792 let mut args = words
793 .into_iter()
794 .map(std::ffi::OsString::from)
795 .collect::<Vec<_>>();
796 args.push(bound_path);
797 args.append(&mut self.args);
798 self.command = std::ffi::OsString::from(interpreter);
799 self.args = args;
800 Ok(())
801 }
802 }
803
804 fn bind_file(
805 &mut self,
806 staged_root: &Path,
807 path: &Path,
808 expected_hashes: &std::collections::BTreeMap<PathBuf, String>,
809 ) -> Result<std::ffi::OsString> {
810 let relative = path
811 .strip_prefix(staged_root)
812 .context("reviewed plugin executable escaped its staged root")?;
813 let expected = expected_hashes
814 .get(relative)
815 .context("reviewed plugin executable is absent from its byte inventory")?;
816 let mut file = open_reviewed_launch_file(path)?;
817 let mut hasher = sha2::Sha256::new();
818 hasher.update(b"codewhale-plugin-file-bytes-v1\0");
819 let mut buffer = [0_u8; 64 * 1024];
820 loop {
821 let read = file
822 .read(&mut buffer)
823 .context("read reviewed launch file")?;
824 if read == 0 {
825 break;
826 }
827 hasher.update(&buffer[..read]);
828 }
829 let actual = hasher
830 .finalize()
831 .iter()
832 .map(|byte| format!("{byte:02x}"))
833 .collect::<String>();
834 if &actual != expected {
835 anyhow::bail!("reviewed plugin executable bytes changed before spawn");
836 }
837 file.seek(std::io::SeekFrom::Start(0))
838 .context("rewind reviewed launch file after verification")?;
839
840 #[cfg(unix)]
841 let launch_path = {
842 use std::os::fd::AsRawFd as _;
843 let fd = file.as_raw_fd();
844 // SAFETY: `fd` is owned by `file`; clearing only FD_CLOEXEC keeps
845 // that same descriptor available across the imminent exec.
846 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
847 if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0
848 {
849 anyhow::bail!("failed to inherit reviewed plugin executable descriptor");
850 }
851 #[cfg(target_os = "linux")]
852 let prefix = "/proc/self/fd";
853 #[cfg(not(target_os = "linux"))]
854 let prefix = "/dev/fd";
855 std::ffi::OsString::from(format!("{prefix}/{fd}"))
856 };
857
858 #[cfg(not(unix))]
859 let launch_path = path.as_os_str().to_os_string();
860
861 self.opened_files.push(file);
862 Ok(launch_path)
863 }
864
865 fn bind_cwd(&mut self, cwd: &Path) -> Result<()> {
866 #[cfg(unix)]
867 {
868 use std::os::unix::fs::OpenOptionsExt as _;
869 let file = fs::OpenOptions::new()
870 .read(true)
871 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
872 .open(cwd)
873 .context("open reviewed plugin cwd without following links")?;
874 self.cwd_fd = Some(file);
875 self.cwd = None;
876 }
877 #[cfg(windows)]
878 {
879 use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
880 let file = fs::OpenOptions::new()
881 .read(true)
882 .share_mode(0x0000_0001) // FILE_SHARE_READ only
883 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
884 .open(cwd)
885 .context("open reviewed plugin cwd without write/delete sharing")?;
886 let metadata = file
887 .metadata()
888 .context("inspect reviewed plugin cwd handle")?;
889 if !metadata.is_dir() || metadata.file_attributes() & 0x0000_0400 != 0 {
890 anyhow::bail!("reviewed plugin cwd is a reparse point or non-directory");
891 }
892 self.opened_files.push(file);
893 }
894 Ok(())
895 }
896 }
897
898 #[cfg(unix)]
899 fn open_reviewed_launch_file(path: &Path) -> Result<fs::File> {
900 crate::plugins::manifest::open_bundle_file(path)
901 .context("open reviewed launch file without following links")
902 }
903
904 #[cfg(windows)]
905 fn open_reviewed_launch_file(path: &Path) -> Result<fs::File> {
906 crate::plugins::manifest::open_bundle_file(path)
907 .context("open reviewed launch file without links, hard links, or write/delete sharing")
908 }
909
910 #[cfg(all(not(unix), not(windows)))]
911 fn open_reviewed_launch_file(path: &Path) -> Result<fs::File> {
912 fs::File::open(path).context("open reviewed launch file")
913 }
914
915 fn reviewed_remote_endpoint_identity(endpoint: &str) -> Result<(String, String)> {
916 let endpoint =
917 reqwest::Url::parse(endpoint).context("reviewed plugin MCP endpoint is invalid")?;
918 if !endpoint.username().is_empty() || endpoint.password().is_some() {
919 anyhow::bail!("reviewed plugin MCP endpoint must not contain user information");
920 }
921 if endpoint.query().is_some() || endpoint.fragment().is_some() {
922 anyhow::bail!("reviewed plugin MCP endpoint must not contain a query or fragment");
923 }
924 let origin = reviewed_remote_origin(&endpoint)
925 .ok_or_else(|| anyhow::anyhow!("reviewed plugin MCP endpoint has an unsafe origin"))?;
926 Ok((endpoint.to_string(), origin))
927 }
928
929 fn reviewed_remote_origin(endpoint: &reqwest::Url) -> Option<String> {
930 if !endpoint.username().is_empty() || endpoint.password().is_some() {
931 return None;
932 }
933 let host = endpoint.host_str()?;
934 let allowed_scheme = endpoint.scheme() == "https"
935 || (endpoint.scheme() == "http"
936 && (host.eq_ignore_ascii_case("localhost")
937 || host
938 .trim_matches(['[', ']'])
939 .parse::<std::net::IpAddr>()
940 .is_ok_and(|address| address.is_loopback())));
941 allowed_scheme.then(|| endpoint.origin().ascii_serialization())
942 }
943
944 fn reviewed_redirect_matches_origin(endpoint: &reqwest::Url, approved_origin: &str) -> bool {
945 reviewed_remote_origin(endpoint).as_deref() == Some(approved_origin)
946 }
947
948 #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
949 pub struct McpServerOAuthConfig {
950 #[serde(default)]
951 #[serde(skip_serializing_if = "Option::is_none")]
952 pub client_id: Option<String>,
953 }
954
955 fn default_enabled() -> bool {
956 true
957 }
958
959 impl McpServerConfig {
960 pub fn effective_connect_timeout(&self, global: &McpTimeouts) -> u64 {
961 self.connect_timeout.unwrap_or(global.connect_timeout)
962 }
963
964 pub fn effective_execute_timeout(&self, global: &McpTimeouts) -> u64 {
965 self.execute_timeout.unwrap_or(global.execute_timeout)
966 }
967
968 pub fn effective_read_timeout(&self, global: &McpTimeouts) -> u64 {
969 self.read_timeout.unwrap_or(global.read_timeout)
970 }
971
972 pub fn is_enabled(&self) -> bool {
973 self.enabled && !self.disabled
974 }
975
976 pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
977 let allowed = if self.enabled_tools.is_empty() {
978 true
979 } else {
980 self.enabled_tools.iter().any(|t| t == tool_name)
981 };
982 if !allowed {
983 return false;
984 }
985 !self.disabled_tools.iter().any(|t| t == tool_name)
986 }
987 }
988
989 // === MCP Tool Definition ===
990
991 /// Tool discovered from an MCP server
992 #[derive(Debug, Clone, Deserialize, Serialize)]
993 pub struct McpTool {
994 pub name: String,
995 #[serde(default)]
996 pub description: Option<String>,
997 #[serde(rename = "inputSchema", default)]
998 pub input_schema: serde_json::Value,
999 }
1000
1001 const MCP_TOOL_DESCRIPTION_MAX_CHARS: usize = 80;
1002
1003 /// Format an optional MCP tool description for terminal list surfaces.
1004 ///
1005 /// CLI and TUI callers share this helper so both stay single-line and truncate
1006 /// on Unicode scalar boundaries rather than slicing UTF-8 bytes.
1007 pub(crate) fn format_mcp_tool_description(description: Option<&str>) -> String {
1008 let Some(first_line) = description
1009 .and_then(|description| description.split(['\r', '\n']).next())
1010 .map(str::trim)
1011 .filter(|description| !description.is_empty())
1012 else {
1013 return String::new();
1014 };
1015
1016 let mut chars = first_line.chars();
1017 let summary: String = chars
1018 .by_ref()
1019 .take(MCP_TOOL_DESCRIPTION_MAX_CHARS)
1020 .collect();
1021 if chars.next().is_some() {
1022 format!(": {summary}...")
1023 } else {
1024 format!(": {summary}")
1025 }
1026 }
1027
1028 /// Resource discovered from an MCP server
1029 #[derive(Debug, Clone, Deserialize, Serialize)]
1030 pub struct McpResource {
1031 pub uri: String,
1032 pub name: String,
1033 #[serde(default)]
1034 pub description: Option<String>,
1035 #[serde(rename = "mimeType", default)]
1036 pub mime_type: Option<String>,
1037 }
1038
1039 /// Resource template discovered from an MCP server
1040 #[derive(Debug, Clone, Deserialize, Serialize)]
1041 pub struct McpResourceTemplate {
1042 #[serde(rename = "uriTemplate")]
1043 pub uri_template: String,
1044 pub name: String,
1045 #[serde(default)]
1046 pub description: Option<String>,
1047 #[serde(rename = "mimeType", default)]
1048 pub mime_type: Option<String>,
1049 }
1050
1051 /// Fail-closed RFC 6570 subset used only as an authorization check. Literal,
1052 /// simple (`{id}`), and reserved (`{+path}`) expansions cover the common MCP
1053 /// resource templates. More elaborate operators remain listable but are not
1054 /// callable until their expansion semantics are implemented exactly.
1055 fn resource_uri_matches_template(uri: &str, template: &str) -> bool {
1056 let mut pattern = String::from("^");
1057 let mut rest = template;
1058 while let Some(start) = rest.find('{') {
1059 pattern.push_str(&regex::escape(&rest[..start]));
1060 let Some(end) = rest[start + 1..].find('}') else {
1061 return false;
1062 };
1063 let expression = &rest[start + 1..start + 1 + end];
1064 let (reserved, variables) = match expression.strip_prefix('+') {
1065 Some(variables) => (true, variables),
1066 None => (false, expression),
1067 };
1068 if variables.is_empty()
1069 || variables.split(',').any(|variable| {
1070 variable.is_empty()
1071 || !variable
1072 .chars()
1073 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
1074 })
1075 {
1076 return false;
1077 }
1078 let atom = if reserved { ".+" } else { "[^/?#]+" };
1079 for (index, _) in variables.split(',').enumerate() {
1080 if index > 0 {
1081 pattern.push(',');
1082 }
1083 pattern.push_str(atom);
1084 }
1085 rest = &rest[start + end + 2..];
1086 }
1087 if rest.contains('}') {
1088 return false;
1089 }
1090 pattern.push_str(&regex::escape(rest));
1091 pattern.push('$');
1092 regex::Regex::new(&pattern).is_ok_and(|regex| regex.is_match(uri))
1093 }
1094
1095 /// Prompt discovered from an MCP server
1096 #[derive(Debug, Clone, Deserialize, Serialize)]
1097 pub struct McpPrompt {
1098 pub name: String,
1099 #[serde(default)]
1100 pub description: Option<String>,
1101 #[serde(default)]
1102 pub arguments: Vec<McpPromptArgument>,
1103 }
1104
1105 /// Argument for an MCP prompt
1106 #[derive(Debug, Clone, Deserialize, Serialize)]
1107 pub struct McpPromptArgument {
1108 pub name: String,
1109 #[serde(default)]
1110 pub description: Option<String>,
1111 #[serde(default)]
1112 pub required: bool,
1113 }
1114
1115 // === Connection State ===
1116
1117 /// State of an MCP connection
1118 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1119 pub enum ConnectionState {
1120 Connecting,
1121 Ready,
1122 Disconnected,
1123 }
1124
1125 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1126 struct McpServerCapabilities {
1127 tools: bool,
1128 resources: bool,
1129 prompts: bool,
1130 }
1131
1132 impl McpServerCapabilities {
1133 fn from_initialize_response(response: &serde_json::Value) -> Option<Self> {
1134 let capabilities = response.get("result")?.get("capabilities")?.as_object()?;
1135 Some(Self {
1136 tools: capabilities.contains_key("tools"),
1137 resources: capabilities.contains_key("resources"),
1138 prompts: capabilities.contains_key("prompts"),
1139 })
1140 }
1141 }
1142
1143 fn response_result<'a>(
1144 response: &'a serde_json::Value,
1145 method: &str,
1146 suppress_server_details: bool,
1147 ) -> Result<Option<&'a serde_json::Value>> {
1148 if let Some(error) = response.get("error") {
1149 if suppress_server_details {
1150 anyhow::bail!(
1151 "Reviewed plugin MCP server returned an error in '{method}' (server details suppressed to protect environment-backed credentials)"
1152 );
1153 }
1154 anyhow::bail!("MCP error in '{method}': {error}");
1155 }
1156 Ok(response.get("result"))
1157 }
1158
1159 async fn run_optional_discovery<F>(server: &str, method: &str, timeout: Duration, discovery: F)
1160 where
1161 F: Future<Output = Result<()>>,
1162 {
1163 match tokio::time::timeout(timeout, discovery).await {
1164 Ok(Ok(())) => {}
1165 Ok(Err(error)) => {
1166 tracing::warn!(
1167 target: "mcp",
1168 server,
1169 method,
1170 error = %error,
1171 "optional MCP discovery failed; continuing with available capabilities"
1172 );
1173 }
1174 Err(error) => {
1175 tracing::warn!(
1176 target: "mcp",
1177 server,
1178 method,
1179 ?timeout,
1180 error = %error,
1181 "optional MCP discovery timed out; continuing with available capabilities"
1182 );
1183 }
1184 }
1185 }
1186
1187 // === McpConnection - Async Connection Management ===
1188
1189 // === Transport Trait ===
1190
1191 #[async_trait::async_trait]
1192 pub trait McpTransport: Send + Sync {
1193 async fn send(&mut self, msg: Vec<u8>) -> Result<()>;
1194 async fn recv(&mut self) -> Result<Vec<u8>>;
1195
1196 /// Graceful shutdown — stdio transports send SIGTERM to the child and
1197 /// give it a brief window to exit before tokio's `kill_on_drop` fires
1198 /// SIGKILL as the backstop. Default is a no-op for non-stdio transports
1199 /// that have no child process. Whalescale#420.
1200 async fn shutdown(&mut self) {}
1201 }
1202
1203 struct HttpTransport {
1204 mode: HttpTransportMode,
1205 client: reqwest::Client,
1206 base_url: String,
1207 auth: McpHttpAuth,
1208 cancel_token: tokio_util::sync::CancellationToken,
1209 endpoint_timeout: Duration,
1210 }
1211
1212 enum HttpTransportMode {
1213 Streamable(StreamableHttpTransport),
1214 Sse(SseTransport),
1215 }
1216
1217 #[derive(Clone, Default)]
1218 struct McpHttpAuth {
1219 server_name: String,
1220 headers: HashMap<String, String>,
1221 env_headers: HashMap<String, String>,
1222 bearer_token_env_var: Option<String>,
1223 oauth: Option<oauth::McpOAuthRuntime>,
1224 suppress_server_error_details: bool,
1225 reviewed_plugin: Option<ReviewedPluginMcpSource>,
1226 }
1227
1228 impl McpHttpAuth {
1229 fn from_config(
1230 server_name: &str,
1231 config: &McpServerConfig,
1232 oauth: Option<oauth::McpOAuthRuntime>,
1233 ) -> Self {
1234 Self {
1235 server_name: server_name.to_string(),
1236 headers: config.headers.clone(),
1237 env_headers: config.env_headers.clone(),
1238 bearer_token_env_var: config.bearer_token_env_var.clone(),
1239 oauth,
1240 suppress_server_error_details: config.reviewed_plugin.is_some(),
1241 reviewed_plugin: config.reviewed_plugin.clone(),
1242 }
1243 }
1244
1245 fn server_error_preview(&self, preview: &str) -> String {
1246 if self.suppress_server_error_details {
1247 "<server details suppressed for reviewed plugin>".to_string()
1248 } else {
1249 preview.to_string()
1250 }
1251 }
1252
1253 async fn resolved_headers(&self) -> Result<HashMap<String, String>> {
1254 if let Some(source) = self.reviewed_plugin.as_ref() {
1255 source.validate_before_use(&self.server_name, "authenticate request to")?;
1256 }
1257 let mut headers = self.headers.clone();
1258 for (name, env_var) in &self.env_headers {
1259 let value = self.reviewed_plugin.as_ref().map_or_else(
1260 || std::env::var(env_var),
1261 |source| source.host_environment.var(env_var),
1262 );
1263 if let Ok(value) = value
1264 && !value.trim().is_empty()
1265 {
1266 headers.insert(name.clone(), value);
1267 }
1268 }
1269 if !mcp_headers_have_authorization(&headers)
1270 && let Some(env_var) = self.bearer_token_env_var.as_deref()
1271 && let Ok(token) = self.reviewed_plugin.as_ref().map_or_else(
1272 || std::env::var(env_var),
1273 |source| source.host_environment.var(env_var),
1274 )
1275 {
1276 let token = token.trim();
1277 if !token.is_empty() {
1278 headers.insert("Authorization".to_string(), format!("Bearer {token}"));
1279 }
1280 }
1281 if !mcp_headers_have_authorization(&headers)
1282 && let Some(oauth) = &self.oauth
1283 {
1284 let authorization = match oauth.authorization_header().await {
1285 Ok(authorization) => authorization,
1286 Err(_) if self.suppress_server_error_details => {
1287 anyhow::bail!(
1288 "Reviewed plugin MCP authentication failed (provider details suppressed)"
1289 )
1290 }
1291 Err(error) => return Err(error),
1292 };
1293 if let Some(value) = authorization {
1294 headers.insert("Authorization".to_string(), value);
1295 }
1296 }
1297 Ok(headers)
1298 }
1299 }
1300
1301 fn mcp_headers_have_authorization(headers: &HashMap<String, String>) -> bool {
1302 headers
1303 .keys()
1304 .any(|key| key.trim().eq_ignore_ascii_case("authorization"))
1305 }
1306
1307 impl HttpTransport {
1308 fn new(
1309 client: reqwest::Client,
1310 url: String,
1311 auth: McpHttpAuth,
1312 cancel_token: tokio_util::sync::CancellationToken,
1313 endpoint_timeout: Duration,
1314 ) -> Self {
1315 Self {
1316 mode: HttpTransportMode::Streamable(StreamableHttpTransport::new(
1317 client.clone(),
1318 url.clone(),
1319 auth.clone(),
1320 )),
1321 client,
1322 base_url: url,
1323 auth,
1324 cancel_token,
1325 endpoint_timeout,
1326 }
1327 }
1328
1329 async fn switch_to_sse_and_send(&mut self, msg: Vec<u8>) -> Result<()> {
1330 let mut sse = SseTransport::connect(
1331 self.client.clone(),
1332 self.base_url.clone(),
1333 self.auth.clone(),
1334 self.cancel_token.clone(),
1335 self.endpoint_timeout,
1336 )
1337 .await?;
1338 sse.send(msg).await?;
1339 self.mode = HttpTransportMode::Sse(sse);
1340 Ok(())
1341 }
1342
1343 /// Best-effort session-establishment GET preflight.
1344 ///
1345 /// Per the Streamable HTTP spec, the server may return an
1346 /// `Mcp-Session-Id` header on the `initialize` response (the normal
1347 /// path handled inside [`StreamableHttpTransport::send`] above).
1348 /// However some servers (e.g. Hindsight, #1629) **require** a session
1349 /// ID on every POST including `initialize`, creating a chicken-and-egg
1350 /// problem. For those servers we send a short-lived GET before the
1351 /// first POST: if the server returns a session ID in the GET response
1352 /// it will be captured by the header-reading code in
1353 /// [`StreamableHttpTransport::send`] just as if it came from a POST
1354 /// response.
1355 ///
1356 /// This is intentionally best-effort:
1357 /// * The GET uses a tight per-request inner timeout so it never
1358 /// blocks connection startup for long.
1359 /// * If the server doesn't support GET (405, 404, …) we log a debug
1360 /// line and move on — the `initialize` POST will proceed without a
1361 /// session ID.
1362 /// * If the server opens an SSE stream in response (the GET from old
1363 /// SSE transport), we read only the headers, then discard the body
1364 /// so the SSE stream is torn down. The actual SSE path uses a
1365 /// dedicated `SseTransport` and is triggered by the incompatible-
1366 /// status fallback in [`HttpTransport::send`].
1367 async fn try_establish_session(&mut self) -> Result<()> {
1368 let cancel = self.cancel_token.clone();
1369 let transport = match &mut self.mode {
1370 HttpTransportMode::Streamable(t) => t,
1371 // Already on SSE — session is implicit via the long-lived GET.
1372 HttpTransportMode::Sse(_) => return Ok(()),
1373 };
1374
1375 let headers = tokio::select! {
1376 biased;
1377 _ = cancel.cancelled() => {
1378 anyhow::bail!("MCP session preflight cancelled after plugin authority changed")
1379 }
1380 headers = transport.auth.resolved_headers() => headers?,
1381 };
1382 let request = apply_safe_custom_headers(
1383 with_default_mcp_http_headers(transport.client.get(&transport.url), false),
1384 &headers,
1385 );
1386 let response = tokio::select! {
1387 biased;
1388 _ = cancel.cancelled() => {
1389 anyhow::bail!("MCP session preflight cancelled after plugin authority changed")
1390 }
1391 response = tokio::time::timeout(Duration::from_secs(5), request.send()) => {
1392 response
1393 .map_err(|_| anyhow::anyhow!("GET timeout"))?
1394 .map_err(|e| anyhow::anyhow!("GET error: {e}"))?
1395 }
1396 };
1397
1398 // Capture session ID from the GET response so subsequent POSTs
1399 // (including `initialize`) can include it. This is the same
1400 // header-reading logic that would be hit inside
1401 // `StreamableHttpTransport::send` for POST responses, but since
1402 // the GET is sent before any POST we do it here directly.
1403 if let Some(sid) = response
1404 .headers()
1405 .get("Mcp-Session-Id")
1406 .and_then(|v| v.to_str().ok())
1407 && transport.session_id.as_deref() != Some(sid)
1408 {
1409 let session_ref = crate::utils::redacted_identifier_for_log(sid);
1410 tracing::debug!(target: "mcp", session = %session_ref, "captured MCP session ID via GET preflight");
1411 transport.session_id = Some(sid.to_string());
1412 }
1413
1414 // We only care about the response headers — discard the body.
1415 // If the server opened an SSE stream in response (some servers
1416 // do this on GET), it will be torn down when response is dropped.
1417 drop(response);
1418
1419 Ok(())
1420 }
1421 }
1422
1423 #[async_trait::async_trait]
1424 impl McpTransport for HttpTransport {
1425 async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
1426 match &mut self.mode {
1427 HttpTransportMode::Streamable(transport) => match transport.send(msg.clone()).await {
1428 Ok(()) => Ok(()),
1429 Err(StreamableSendError::Incompatible(detail)) => {
1430 tracing::debug!(
1431 "MCP Streamable HTTP unavailable; falling back to SSE endpoint discovery: {}",
1432 detail
1433 );
1434 self.switch_to_sse_and_send(msg).await
1435 }
1436 Err(StreamableSendError::StaleSession(detail)) => {
1437 if let HttpTransportMode::Streamable(transport) = &mut self.mode {
1438 tracing::debug!(
1439 target: "mcp",
1440 error = %detail,
1441 "MCP Streamable HTTP session expired; clearing cached session ID"
1442 );
1443 transport.session_id = None;
1444 }
1445 Err(anyhow::anyhow!(
1446 "MCP Streamable HTTP session expired; retry with a new session required ({detail})"
1447 ))
1448 }
1449 Err(StreamableSendError::Other(err)) => Err(err),
1450 },
1451 HttpTransportMode::Sse(transport) => transport.send(msg).await,
1452 }
1453 }
1454
1455 async fn recv(&mut self) -> Result<Vec<u8>> {
1456 match &mut self.mode {
1457 HttpTransportMode::Streamable(transport) => transport.recv().await,
1458 HttpTransportMode::Sse(transport) => transport.recv().await,
1459 }
1460 }
1461
1462 async fn shutdown(&mut self) {
1463 if let HttpTransportMode::Sse(transport) = &mut self.mode {
1464 transport.shutdown().await;
1465 }
1466 }
1467 }
1468
1469 fn is_mcp_stale_session_body(body: &str) -> bool {
1470 let body = body.to_ascii_lowercase();
1471 body.contains("session") && (body.contains("expired") || body.contains("invalid"))
1472 }
1473
1474 fn is_mcp_stale_session_error(err: &anyhow::Error) -> bool {
1475 let err = format!("{err:#}");
1476 let lower_err = err.to_ascii_lowercase();
1477 err.contains("MCP Streamable HTTP session expired")
1478 || err.contains("MCP session expired")
1479 || err.contains("SSE transport closed")
1480 || (err.contains("MCP SSE POST send failed") && is_connection_closed_error_text(&lower_err))
1481 || is_mcp_stale_session_body(&err)
1482 }
1483
1484 fn is_connection_closed_error_text(err: &str) -> bool {
1485 err.contains("connection closed")
1486 || err.contains("connection reset")
1487 || err.contains("broken pipe")
1488 || err.contains("unexpected eof")
1489 || err.contains("forcibly closed")
1490 }
1491
1492 fn parse_sse_message_data(body: &str) -> Vec<Vec<u8>> {
1493 let normalized = body.replace("\r\n", "\n");
1494 let mut messages = Vec::new();
1495
1496 for block in normalized.split("\n\n") {
1497 let mut event_type = "message";
1498 let mut data = String::new();
1499
1500 for line in block.lines() {
1501 if let Some(value) = sse_field_value(line, "event:") {
1502 event_type = value;
1503 } else if let Some(value) = sse_field_value(line, "data:") {
1504 if !data.is_empty() {
1505 data.push('\n');
1506 }
1507 data.push_str(value);
1508 }
1509 }
1510
1511 if event_type != "message" || data.trim().is_empty() {
1512 continue;
1513 }
1514
1515 messages.push(data.trim().as_bytes().to_vec());
1516 }
1517
1518 messages
1519 }
1520
1521 // Retained for tests; the SSE transport now uses the byte-oriented twin.
1522 #[cfg(test)]
1523 fn find_sse_event_separator(buffer: &str) -> Option<(usize, usize)> {
1524 match (buffer.find("\n\n"), buffer.find("\r\n\r\n")) {
1525 (Some(lf), Some(crlf)) if crlf < lf => Some((crlf, 4)),
1526 (Some(lf), _) => Some((lf, 2)),
1527 (_, Some(crlf)) => Some((crlf, 4)),
1528 _ => None,
1529 }
1530 }
1531
1532 /// Byte-oriented twin of `find_sse_event_separator`. Used by the SSE
1533 /// transport so it can accumulate RAW bytes and decode only complete event
1534 /// blocks — a multi-byte UTF-8 char split across two network reads is never
1535 /// corrupted to U+FFFD (the `\n`/`\r` separators are ASCII and can never fall
1536 /// inside a multi-byte sequence).
1537 fn find_sse_event_separator_bytes(buffer: &[u8]) -> Option<(usize, usize)> {
1538 let lf = buffer.windows(2).position(|w| w == b"\n\n");
1539 let crlf = buffer.windows(4).position(|w| w == b"\r\n\r\n");
1540 match (lf, crlf) {
1541 (Some(lf), Some(crlf)) if crlf < lf => Some((crlf, 4)),
1542 (Some(lf), _) => Some((lf, 2)),
1543 (_, Some(crlf)) => Some((crlf, 4)),
1544 _ => None,
1545 }
1546 }
1547
1548 /// Hard ceiling on the SSE frame-assembly buffer. A server that never emits a
1549 /// frame separator would otherwise grow it without bound (OOM DoS).
1550 pub(super) const MAX_SSE_FRAME_BYTES: usize = 8 * 1024 * 1024;
1551
1552 /// Hard ceiling on a single MCP HTTP response body / stdio line. A misbehaving
1553 /// or malicious server could otherwise stream an unbounded body (or a
1554 /// newline-free multi-GB "line") and OOM the process at transport-read time,
1555 /// before any transcript-level spillover applies.
1556 pub(super) const MAX_MCP_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
1557 const MAX_MCP_CATALOG_PAGES: usize = 64;
1558 const MAX_MCP_CATALOG_ITEMS: usize = 4_096;
1559 const MAX_MCP_CATALOG_BYTES: usize = 32 * 1024 * 1024;
1560
1561 struct McpCatalogBudget {
1562 method: &'static str,
1563 pages: usize,
1564 items: usize,
1565 bytes: usize,
1566 seen_cursors: HashSet<String>,
1567 }
1568
1569 impl McpCatalogBudget {
1570 fn new(method: &'static str) -> Self {
1571 Self {
1572 method,
1573 pages: 0,
1574 items: 0,
1575 bytes: 0,
1576 seen_cursors: HashSet::new(),
1577 }
1578 }
1579
1580 fn observe_page(
1581 &mut self,
1582 result: &serde_json::Value,
1583 item_count: usize,
1584 ) -> Result<Option<String>> {
1585 self.pages = self.pages.saturating_add(1);
1586 self.items = self.items.saturating_add(item_count);
1587 self.bytes = self.bytes.saturating_add(serde_json::to_vec(result)?.len());
1588 if self.pages > MAX_MCP_CATALOG_PAGES {
1589 anyhow::bail!(
1590 "{} exceeded the {}-page catalogue limit",
1591 self.method,
1592 MAX_MCP_CATALOG_PAGES
1593 );
1594 }
1595 if self.items > MAX_MCP_CATALOG_ITEMS {
1596 anyhow::bail!(
1597 "{} exceeded the {}-item catalogue limit",
1598 self.method,
1599 MAX_MCP_CATALOG_ITEMS
1600 );
1601 }
1602 if self.bytes > MAX_MCP_CATALOG_BYTES {
1603 anyhow::bail!(
1604 "{} exceeded the {}-byte aggregate catalogue limit",
1605 self.method,
1606 MAX_MCP_CATALOG_BYTES
1607 );
1608 }
1609 let cursor = result
1610 .get("nextCursor")
1611 .and_then(|value| value.as_str())
1612 .map(str::to_owned);
1613 if let Some(cursor) = cursor.as_ref()
1614 && !self.seen_cursors.insert(cursor.clone())
1615 {
1616 anyhow::bail!("{} repeated pagination cursor; aborting", self.method);
1617 }
1618 Ok(cursor)
1619 }
1620 }
1621
1622 fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> {
1623 let value = line.strip_prefix(field)?;
1624 Some(value.strip_prefix(' ').unwrap_or(value))
1625 }
1626
1627 fn is_legacy_sse_transport(config: &McpServerConfig) -> bool {
1628 config
1629 .transport
1630 .as_deref()
1631 .map(|transport| transport.trim().eq_ignore_ascii_case("sse"))
1632 .unwrap_or(false)
1633 }
1634
1635 fn validate_mcp_transport(transport: Option<&str>) -> Result<()> {
1636 let Some(transport) = transport else {
1637 return Ok(());
1638 };
1639 if transport.trim().eq_ignore_ascii_case("sse") {
1640 return Ok(());
1641 }
1642 anyhow::bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
1643 }
1644
1645 fn response_id_matches(id: Option<&serde_json::Value>, expected_id: &str) -> bool {
1646 let Some(id) = id else {
1647 return false;
1648 };
1649 if id.as_str() == Some(expected_id) {
1650 return true;
1651 }
1652 id.as_u64()
1653 .map(|id| id.to_string() == expected_id)
1654 .unwrap_or(false)
1655 }
1656
1657 // === McpConnection - Async Connection Management ===
1658
1659 /// Manages a single async connection to an MCP server
1660 pub struct McpConnection {
1661 name: String,
1662 transport: Box<dyn McpTransport>,
1663 tools: Vec<McpTool>,
1664 resources: Vec<McpResource>,
1665 resource_templates: Vec<McpResourceTemplate>,
1666 prompts: Vec<McpPrompt>,
1667 request_id: AtomicU64,
1668 state: ConnectionState,
1669 config: McpServerConfig,
1670 server_capabilities: Option<McpServerCapabilities>,
1671 discovery_timeout: Duration,
1672 read_timeout_secs: u64,
1673 cancel_token: tokio_util::sync::CancellationToken,
1674 authority_revocation_reason: Arc<std::sync::Mutex<Option<String>>>,
1675 authority_watch: Option<tokio::task::JoinHandle<()>>,
1676 /// Pool catalog generation that created/last authorized this connection.
1677 /// Directly constructed test connections use zero until inserted.
1678 catalog_generation: u64,
1679 }
1680
1681 struct PendingAuthorityWatch {
1682 handle: Option<tokio::task::JoinHandle<()>>,
1683 cancel: tokio_util::sync::CancellationToken,
1684 armed: bool,
1685 }
1686
1687 impl PendingAuthorityWatch {
1688 fn start(
1689 source: ReviewedPluginMcpSource,
1690 cancel: tokio_util::sync::CancellationToken,
1691 reason_slot: Arc<std::sync::Mutex<Option<String>>>,
1692 ) -> Self {
1693 let task_cancel = cancel.clone();
1694 let handle = tokio::spawn(async move {
1695 loop {
1696 if let Err(reason) =
1697 crate::plugins::registry::verify_plugin_state_authority(&source.authority)
1698 {
1699 if let Ok(mut slot) = reason_slot.lock() {
1700 *slot = Some(reason);
1701 }
1702 task_cancel.cancel();
1703 break;
1704 }
1705 tokio::select! {
1706 _ = task_cancel.cancelled() => break,
1707 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
1708 }
1709 }
1710 });
1711 Self {
1712 handle: Some(handle),
1713 cancel,
1714 armed: true,
1715 }
1716 }
1717
1718 fn disarm(mut self) -> tokio::task::JoinHandle<()> {
1719 self.armed = false;
1720 self.handle.take().expect("authority watch must exist")
1721 }
1722 }
1723
1724 impl Drop for PendingAuthorityWatch {
1725 fn drop(&mut self) {
1726 if self.armed {
1727 self.cancel.cancel();
1728 if let Some(handle) = self.handle.take() {
1729 handle.abort();
1730 }
1731 }
1732 }
1733 }
1734
1735 impl McpConnection {
1736 /// Connect to an MCP server and initialize it.
1737 ///
1738 /// `network_policy` (added in v0.7.0 for #135) is consulted for HTTP/SSE
1739 /// transports only — STDIO transports are unaffected. Pass `None` to
1740 /// match pre-v0.7.0 permissive behavior.
1741 pub async fn connect_with_policy(
1742 name: String,
1743 config: McpServerConfig,
1744 global_timeouts: &McpTimeouts,
1745 network_policy: Option<&NetworkPolicyDecider>,
1746 ) -> Result<Self> {
1747 let connect_timeout_secs = config.effective_connect_timeout(global_timeouts);
1748 let read_timeout_secs = config.effective_read_timeout(global_timeouts);
1749 let cancel_token = tokio_util::sync::CancellationToken::new();
1750 let authority_revocation_reason = Arc::new(std::sync::Mutex::new(None));
1751 if let Some(source) = config.reviewed_plugin.as_ref() {
1752 source.validate_before_use(&name, "connect")?;
1753 if let Some(url) = config.url.as_deref() {
1754 source.validate_remote_endpoint(&name, url)?;
1755 }
1756 }
1757 // Start the cross-process generation watch before any network request
1758 // or child spawn. The guard cancels and aborts itself on every early
1759 // return; a successful connection transfers the task into `Self`.
1760 let authority_watch = config.reviewed_plugin.clone().map(|source| {
1761 PendingAuthorityWatch::start(
1762 source,
1763 cancel_token.clone(),
1764 Arc::clone(&authority_revocation_reason),
1765 )
1766 });
1767 let transport: Box<dyn McpTransport> = if let Some(url) = &config.url {
1768 // Per-domain network policy gate (#135). Only the HTTP/SSE transport
1769 // is gated; STDIO MCP servers run as local subprocesses and never
1770 // touch the network from this code path.
1771 if let Some(decider) = network_policy
1772 && let Some(host) = host_from_url(url)
1773 {
1774 match decider.evaluate(&host, "mcp") {
1775 Decision::Allow => {}
1776 Decision::Deny => {
1777 anyhow::bail!(
1778 "MCP server '{name}' connection to '{host}' blocked by network policy"
1779 );
1780 }
1781 Decision::Prompt => {
1782 anyhow::bail!(
1783 "MCP server '{name}' connection to '{host}' requires approval; \
1784 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
1785 );
1786 }
1787 }
1788 }
1789 // Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` (and their
1790 // lowercase equivalents) plus `NO_PROXY` env vars when
1791 // reaching MCP HTTP servers (#1408). Reqwest 0.13 does not
1792 // auto-detect these by default, so users behind corporate
1793 // proxies, on China-mainland connections routing through a
1794 // local Clash / Shadowsocks tunnel, etc. previously had MCP
1795 // HTTP traffic bypass the proxy entirely while every other
1796 // tool on the box (curl, npm, …) used it.
1797 // `connect_timeout` bounds only the connect phase; the total request
1798 // timeout is the read timeout (a sane backstop) so per-call
1799 // execute_timeout can actually govern request duration. Previously
1800 // this set reqwest's TOTAL `.timeout()` from connect_timeout (10s),
1801 // which silently capped every request at 10s and made the per-server
1802 // execute_timeout / read_timeout dead for HTTP transports.
1803 let mut client_builder = crate::tls::reqwest_client_builder()
1804 .connect_timeout(Duration::from_secs(connect_timeout_secs))
1805 .timeout(Duration::from_secs(read_timeout_secs));
1806 if let Some(approved_origin) = config
1807 .reviewed_plugin
1808 .as_ref()
1809 .and_then(|source| source.approved_remote_origin.clone())
1810 {
1811 client_builder =
1812 client_builder.redirect(reqwest::redirect::Policy::custom(move |attempt| {
1813 if attempt.previous().len() >= 5 {
1814 return attempt.stop();
1815 }
1816 if reviewed_redirect_matches_origin(attempt.url(), &approved_origin) {
1817 attempt.follow()
1818 } else {
1819 attempt.stop()
1820 }
1821 }));
1822 }
1823 client_builder =
1824 configure_mcp_proxy(client_builder, config.reviewed_plugin.is_some(), |name| {
1825 std::env::var(name)
1826 });
1827 let client = client_builder.build()?;
1828 let oauth_runtime = if config.reviewed_plugin.is_some() {
1829 None
1830 } else {
1831 match oauth::build_default_headers(&config.headers, &config.env_headers) {
1832 Ok(default_headers) => {
1833 let prepared = tokio::select! {
1834 biased;
1835 _ = cancel_token.cancelled() => {
1836 anyhow::bail!(
1837 "MCP OAuth setup cancelled after plugin authority changed"
1838 )
1839 }
1840 prepared = oauth::McpOAuthRuntime::from_server_config(
1841 &name,
1842 &config,
1843 default_headers,
1844 ) => prepared,
1845 };
1846 match prepared {
1847 Ok(runtime) => runtime,
1848 Err(err) => {
1849 if config.reviewed_plugin.is_some() {
1850 tracing::warn!(
1851 target: "mcp",
1852 server = %name,
1853 "failed to prepare reviewed plugin MCP OAuth runtime; provider details suppressed; continuing without stored OAuth token"
1854 );
1855 } else {
1856 tracing::warn!(
1857 target: "mcp",
1858 server = %name,
1859 error = %err,
1860 "failed to prepare MCP OAuth runtime; continuing without stored OAuth token"
1861 );
1862 }
1863 None
1864 }
1865 }
1866 }
1867 Err(err) => {
1868 if config.reviewed_plugin.is_some() {
1869 tracing::warn!(
1870 target: "mcp",
1871 server = %name,
1872 "failed to prepare reviewed plugin MCP OAuth headers; details suppressed; continuing without stored OAuth token"
1873 );
1874 } else {
1875 tracing::warn!(
1876 target: "mcp",
1877 server = %name,
1878 error = %err,
1879 "failed to prepare MCP OAuth default headers; continuing without stored OAuth token"
1880 );
1881 }
1882 None
1883 }
1884 }
1885 };
1886 let http_auth = McpHttpAuth::from_config(&name, &config, oauth_runtime);
1887 if is_legacy_sse_transport(&config) {
1888 Box::new(
1889 SseTransport::connect(
1890 client,
1891 url.clone(),
1892 http_auth,
1893 cancel_token.clone(),
1894 Duration::from_secs(connect_timeout_secs),
1895 )
1896 .await?,
1897 )
1898 } else {
1899 let mut http = HttpTransport::new(
1900 client,
1901 url.clone(),
1902 http_auth,
1903 cancel_token.clone(),
1904 Duration::from_secs(connect_timeout_secs),
1905 );
1906 // Best-effort session preflight for servers that require
1907 // a session ID on every POST including `initialize`
1908 // (e.g. Hindsight, #1629). Failures are non-fatal — the
1909 // `initialize` POST will proceed and may capture a session
1910 // ID from the response instead.
1911 if let Err(e) = http.try_establish_session().await {
1912 tracing::debug!(
1913 target: "mcp",
1914 server = %name,
1915 error = %e,
1916 "session-establishment GET skipped; proceeding with POST initialize"
1917 );
1918 }
1919 Box::new(http)
1920 }
1921 } else if let Some(command) = &config.command {
1922 Box::new(StdioTransport::spawn(
1923 &name,
1924 command,
1925 &config,
1926 cancel_token.clone(),
1927 )?)
1928 } else {
1929 anyhow::bail!("MCP server '{name}' config must have either 'command' or 'url'");
1930 };
1931 // Revalidate after transport construction as well: remote setup may
1932 // await DNS/TLS/SSE preflight, and a concurrent process can revoke the
1933 // receipt during that interval. Initialization and catalog discovery
1934 // never start under a stale generation.
1935 if let Some(source) = config.reviewed_plugin.as_ref() {
1936 source.validate_before_use(&name, "initialize")?;
1937 }
1938 let authority_watch = authority_watch.map(PendingAuthorityWatch::disarm);
1939
1940 let mut conn = Self {
1941 name: name.clone(),
1942 transport,
1943 tools: Vec::new(),
1944 resources: Vec::new(),
1945 resource_templates: Vec::new(),
1946 prompts: Vec::new(),
1947 request_id: AtomicU64::new(1),
1948 state: ConnectionState::Connecting,
1949 config,
1950 server_capabilities: None,
1951 discovery_timeout: Duration::from_secs(connect_timeout_secs),
1952 read_timeout_secs,
1953 cancel_token,
1954 authority_revocation_reason,
1955 authority_watch,
1956 catalog_generation: 0,
1957 };
1958
1959 // Initialize with timeout
1960 tokio::time::timeout(Duration::from_secs(connect_timeout_secs), conn.initialize())
1961 .await
1962 .with_context(|| format!("MCP server '{name}' initialization timed out"))??;
1963
1964 conn.discover_all()
1965 .await
1966 .with_context(|| format!("MCP server '{name}' discovery failed"))?;
1967
1968 conn.state = ConnectionState::Ready;
1969 Ok(conn)
1970 }
1971
1972 /// Send initialize request and wait for response
1973 async fn initialize(&mut self) -> Result<()> {
1974 let init_id = self.next_id();
1975 self.send(serde_json::json!({
1976 "jsonrpc": "2.0",
1977 "id": &init_id,
1978 "method": "initialize",
1979 "params": {
1980 "protocolVersion": "2024-11-05",
1981 "clientInfo": {
1982 "name": "codewhale-tui",
1983 "version": env!("CARGO_PKG_VERSION")
1984 },
1985 "capabilities": {
1986 "tools": {},
1987 "resources": {},
1988 "prompts": {}
1989 }
1990 }
1991 }))
1992 .await?;
1993
1994 let response = self.recv(init_id).await?;
1995 response_result(
1996 &response,
1997 "initialize",
1998 self.config.reviewed_plugin.is_some(),
1999 )?;
2000 self.server_capabilities = McpServerCapabilities::from_initialize_response(&response);
2001
2002 // Send initialized notification (no id, no response expected)
2003 self.send(serde_json::json!({
2004 "jsonrpc": "2.0",
2005 "method": "notifications/initialized"
2006 }))
2007 .await?;
2008
2009 Ok(())
2010 }
2011
2012 /// Discover tools, resources, and prompts
2013 async fn discover_all(&mut self) -> Result<()> {
2014 let capabilities = self.server_capabilities;
2015 let server = self.name.clone();
2016 let discovery_timeout = self.discovery_timeout;
2017
2018 // Missing initialize metadata is treated as a legacy/unknown server:
2019 // retain tool discovery and bounded best-effort probes for compatibility.
2020 // When capabilities are advertised, do not call methods the server says
2021 // it does not implement (notably JetBrains tools-only MCP servers).
2022 if capabilities.is_none_or(|capabilities| capabilities.tools) {
2023 tokio::time::timeout(discovery_timeout, self.discover_tools())
2024 .await
2025 .with_context(|| {
2026 format!(
2027 "MCP server '{}' tool discovery timed out after {:?}",
2028 server, discovery_timeout
2029 )
2030 })??;
2031 }
2032
2033 // Keep all three optional calls within one discovery-timeout budget in
2034 // the worst case while also respecting a tighter transport read timeout.
2035 let optional_timeout =
2036 (discovery_timeout / 3).min(Duration::from_secs(self.read_timeout_secs));
2037 if capabilities.is_none_or(|capabilities| capabilities.resources) {
2038 run_optional_discovery(
2039 &server,
2040 "resources/list",
2041 optional_timeout,
2042 self.discover_resources(),
2043 )
2044 .await;
2045 run_optional_discovery(
2046 &server,
2047 "resources/templates/list",
2048 optional_timeout,
2049 self.discover_resource_templates(),
2050 )
2051 .await;
2052 }
2053 if capabilities.is_none_or(|capabilities| capabilities.prompts) {
2054 run_optional_discovery(
2055 &server,
2056 "prompts/list",
2057 optional_timeout,
2058 self.discover_prompts(),
2059 )
2060 .await;
2061 }
2062 Ok(())
2063 }
2064
2065 /// Discover available tools from the MCP server
2066 async fn discover_tools(&mut self) -> Result<()> {
2067 let mut cursor: Option<String> = None;
2068 let mut budget = McpCatalogBudget::new("tools/list");
2069 let mut discovered = Vec::new();
2070 loop {
2071 let list_id = self.next_id();
2072 let params = match &cursor {
2073 Some(c) => serde_json::json!({ "cursor": c }),
2074 None => serde_json::json!({}),
2075 };
2076 self.send(serde_json::json!({
2077 "jsonrpc": "2.0",
2078 "id": &list_id,
2079 "method": "tools/list",
2080 "params": params
2081 }))
2082 .await?;
2083
2084 let response = self.recv(list_id).await?;
2085 let Some(result) = response_result(
2086 &response,
2087 "tools/list",
2088 self.config.reviewed_plugin.is_some(),
2089 )?
2090 else {
2091 break;
2092 };
2093
2094 let items = result
2095 .get("tools")
2096 .and_then(|tools| tools.as_array())
2097 .map_or(0, Vec::len);
2098 if let Some(arr) = result.get("tools").and_then(|t| t.as_array()) {
2099 for item in arr {
2100 match serde_json::from_value::<McpTool>(item.clone()) {
2101 Ok(tool) => discovered.push(tool),
2102 Err(err) => {
2103 // Skip individual malformed entries instead of
2104 // dropping the whole page (#1410). The old
2105 // `unwrap_or_default()` would silently throw
2106 // away every tool when one was misshapen.
2107 tracing::debug!(target: "mcp", ?err, "skipping malformed tool item");
2108 }
2109 }
2110 }
2111 }
2112
2113 cursor = budget.observe_page(result, items)?;
2114 if cursor.is_none() {
2115 break;
2116 }
2117 }
2118 // Sort by tool name so the order the model sees doesn't depend on
2119 // server-side pagination ordering — keeps the prompt prefix stable
2120 // for cache-hit purposes (#1319).
2121 discovered.sort_by(|a, b| a.name.cmp(&b.name));
2122 self.tools = discovered;
2123 Ok(())
2124 }
2125
2126 /// Discover available resources from the MCP server
2127 async fn discover_resources(&mut self) -> Result<()> {
2128 let mut cursor: Option<String> = None;
2129 let mut budget = McpCatalogBudget::new("resources/list");
2130 let mut discovered = Vec::new();
2131 loop {
2132 let list_id = self.next_id();
2133 let params = match &cursor {
2134 Some(c) => serde_json::json!({ "cursor": c }),
2135 None => serde_json::json!({}),
2136 };
2137 self.send(serde_json::json!({
2138 "jsonrpc": "2.0",
2139 "id": &list_id,
2140 "method": "resources/list",
2141 "params": params
2142 }))
2143 .await?;
2144
2145 let response = self.recv(list_id).await?;
2146 let Some(result) = response_result(
2147 &response,
2148 "resources/list",
2149 self.config.reviewed_plugin.is_some(),
2150 )?
2151 else {
2152 break;
2153 };
2154
2155 let items = result
2156 .get("resources")
2157 .and_then(|resources| resources.as_array())
2158 .map_or(0, Vec::len);
2159 if let Some(arr) = result.get("resources").and_then(|r| r.as_array()) {
2160 for item in arr {
2161 match serde_json::from_value::<McpResource>(item.clone()) {
2162 Ok(resource) => discovered.push(resource),
2163 Err(err) => {
2164 tracing::debug!(target: "mcp", ?err, "skipping malformed resource item");
2165 }
2166 }
2167 }
2168 }
2169
2170 cursor = budget.observe_page(result, items)?;
2171 if cursor.is_none() {
2172 break;
2173 }
2174 }
2175 self.resources = discovered;
2176 Ok(())
2177 }
2178
2179 /// Discover available resource templates from the MCP server
2180 async fn discover_resource_templates(&mut self) -> Result<()> {
2181 let mut cursor: Option<String> = None;
2182 let mut budget = McpCatalogBudget::new("resources/templates/list");
2183 let mut discovered = Vec::new();
2184 loop {
2185 let list_id = self.next_id();
2186 let params = match &cursor {
2187 Some(c) => serde_json::json!({ "cursor": c }),
2188 None => serde_json::json!({}),
2189 };
2190 self.send(serde_json::json!({
2191 "jsonrpc": "2.0",
2192 "id": &list_id,
2193 "method": "resources/templates/list",
2194 "params": params
2195 }))
2196 .await?;
2197
2198 let response = self.recv(list_id).await?;
2199 let Some(result) = response_result(
2200 &response,
2201 "resources/templates/list",
2202 self.config.reviewed_plugin.is_some(),
2203 )?
2204 else {
2205 break;
2206 };
2207
2208 let templates = result
2209 .get("resourceTemplates")
2210 .or_else(|| result.get("templates"))
2211 .or_else(|| result.get("resource_templates"));
2212 let items = templates
2213 .and_then(|templates| templates.as_array())
2214 .map_or(0, Vec::len);
2215 if let Some(arr) = templates.and_then(|t| t.as_array()) {
2216 for item in arr {
2217 match serde_json::from_value::<McpResourceTemplate>(item.clone()) {
2218 Ok(tmpl) => discovered.push(tmpl),
2219 Err(err) => {
2220 tracing::debug!(target: "mcp", ?err, "skipping malformed resource_template item");
2221 }
2222 }
2223 }
2224 }
2225
2226 cursor = budget.observe_page(result, items)?;
2227 if cursor.is_none() {
2228 break;
2229 }
2230 }
2231 self.resource_templates = discovered;
2232 Ok(())
2233 }
2234
2235 /// Discover available prompts from the MCP server
2236 async fn discover_prompts(&mut self) -> Result<()> {
2237 let mut cursor: Option<String> = None;
2238 let mut budget = McpCatalogBudget::new("prompts/list");
2239 let mut discovered = Vec::new();
2240 loop {
2241 let list_id = self.next_id();
2242 let params = match &cursor {
2243 Some(c) => serde_json::json!({ "cursor": c }),
2244 None => serde_json::json!({}),
2245 };
2246 self.send(serde_json::json!({
2247 "jsonrpc": "2.0",
2248 "id": &list_id,
2249 "method": "prompts/list",
2250 "params": params
2251 }))
2252 .await?;
2253
2254 let response = self.recv(list_id).await?;
2255 let Some(result) = response_result(
2256 &response,
2257 "prompts/list",
2258 self.config.reviewed_plugin.is_some(),
2259 )?
2260 else {
2261 break;
2262 };
2263
2264 let items = result
2265 .get("prompts")
2266 .and_then(|prompts| prompts.as_array())
2267 .map_or(0, Vec::len);
2268 if let Some(arr) = result.get("prompts").and_then(|p| p.as_array()) {
2269 for item in arr {
2270 match serde_json::from_value::<McpPrompt>(item.clone()) {
2271 Ok(prompt) => discovered.push(prompt),
2272 Err(err) => {
2273 tracing::debug!(target: "mcp", ?err, "skipping malformed prompt item");
2274 }
2275 }
2276 }
2277 }
2278
2279 cursor = budget.observe_page(result, items)?;
2280 if cursor.is_none() {
2281 break;
2282 }
2283 }
2284 self.prompts = discovered;
2285 Ok(())
2286 }
2287
2288 /// Call a tool on this MCP server
2289 pub async fn call_tool(
2290 &mut self,
2291 tool_name: &str,
2292 arguments: serde_json::Value,
2293 timeout_secs: u64,
2294 ) -> Result<serde_json::Value> {
2295 self.call_method(
2296 "tools/call",
2297 serde_json::json!({
2298 "name": tool_name,
2299 "arguments": arguments
2300 }),
2301 timeout_secs,
2302 )
2303 .await
2304 }
2305
2306 /// Read a resource from this MCP server
2307 pub async fn read_resource(
2308 &mut self,
2309 uri: &str,
2310 timeout_secs: u64,
2311 ) -> Result<serde_json::Value> {
2312 self.call_method(
2313 "resources/read",
2314 serde_json::json!({
2315 "uri": uri
2316 }),
2317 timeout_secs,
2318 )
2319 .await
2320 }
2321
2322 /// Get a prompt from this MCP server
2323 pub async fn get_prompt(
2324 &mut self,
2325 prompt_name: &str,
2326 arguments: serde_json::Value,
2327 timeout_secs: u64,
2328 ) -> Result<serde_json::Value> {
2329 self.call_method(
2330 "prompts/get",
2331 serde_json::json!({
2332 "name": prompt_name,
2333 "arguments": arguments
2334 }),
2335 timeout_secs,
2336 )
2337 .await
2338 }
2339
2340 /// Generic method to call an MCP method
2341 async fn call_method(
2342 &mut self,
2343 method: &str,
2344 params: serde_json::Value,
2345 timeout_secs: u64,
2346 ) -> Result<serde_json::Value> {
2347 if self.state != ConnectionState::Ready {
2348 anyhow::bail!(
2349 "Failed to call MCP method '{}': connection '{}' is not ready",
2350 method,
2351 self.name
2352 );
2353 }
2354 if let Some(source) = self.config.reviewed_plugin.as_ref() {
2355 source.validate_before_use(&self.name, method)?;
2356 }
2357
2358 let call_id = self.next_id();
2359 if let Err(error) = self
2360 .send(serde_json::json!({
2361 "jsonrpc": "2.0",
2362 "id": &call_id,
2363 "method": method,
2364 "params": params
2365 }))
2366 .await
2367 {
2368 return self.finish_guarded_error(error).await;
2369 }
2370
2371 let response =
2372 match tokio::time::timeout(Duration::from_secs(timeout_secs), self.recv(call_id))
2373 .await
2374 .with_context(|| {
2375 format!(
2376 "MCP method '{}' on server '{}' timed out after {}s",
2377 method, self.name, timeout_secs
2378 )
2379 }) {
2380 Ok(Ok(response)) => response,
2381 Ok(Err(error)) => return self.finish_guarded_error(error).await,
2382 Err(error) => return self.finish_guarded_error(error).await,
2383 };
2384
2385 if let Some(error) = response.get("error") {
2386 if self.config.reviewed_plugin.is_some() {
2387 anyhow::bail!(
2388 "Reviewed plugin MCP server returned an error in '{method}' (server details suppressed to protect environment-backed credentials)"
2389 );
2390 }
2391 return Err(anyhow::anyhow!(
2392 "MCP error in '{}': {}",
2393 method,
2394 serde_json::to_string_pretty(error)?
2395 ));
2396 }
2397
2398 Ok(response
2399 .get("result")
2400 .cloned()
2401 .unwrap_or(serde_json::json!(null)))
2402 }
2403
2404 /// Get discovered tools
2405 pub fn tools(&self) -> &[McpTool] {
2406 &self.tools
2407 }
2408
2409 /// Get discovered resources
2410 pub fn resources(&self) -> &[McpResource] {
2411 &self.resources
2412 }
2413
2414 /// Get discovered resource templates
2415 pub fn resource_templates(&self) -> &[McpResourceTemplate] {
2416 &self.resource_templates
2417 }
2418
2419 /// Get discovered prompts
2420 pub fn prompts(&self) -> &[McpPrompt] {
2421 &self.prompts
2422 }
2423
2424 /// Get server name
2425 #[allow(dead_code)] // Public API for MCP consumers
2426 pub fn name(&self) -> &str {
2427 &self.name
2428 }
2429
2430 /// Check if connection is ready
2431 pub fn is_ready(&self) -> bool {
2432 self.state == ConnectionState::Ready && self.catalog_authorized()
2433 }
2434
2435 /// Get server config
2436 pub fn config(&self) -> &McpServerConfig {
2437 &self.config
2438 }
2439
2440 /// Get connection state
2441 #[allow(dead_code)] // Public API for MCP consumers
2442 pub fn state(&self) -> ConnectionState {
2443 self.state
2444 }
2445
2446 fn next_id(&self) -> String {
2447 self.request_id.fetch_add(1, Ordering::SeqCst).to_string()
2448 }
2449
2450 async fn send(&mut self, msg: serde_json::Value) -> Result<()> {
2451 let bytes = serde_json::to_vec(&msg).context("Failed to serialize MCP JSON-RPC message")?;
2452 tokio::select! {
2453 biased;
2454 _ = self.cancel_token.cancelled() => {
2455 self.state = ConnectionState::Disconnected;
2456 anyhow::bail!("MCP connection '{}' was cancelled", self.name)
2457 }
2458 result = self.transport.send(bytes) => result,
2459 }
2460 }
2461
2462 async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
2463 loop {
2464 let bytes = match tokio::time::timeout(
2465 Duration::from_secs(self.read_timeout_secs),
2466 async {
2467 tokio::select! {
2468 biased;
2469 _ = self.cancel_token.cancelled() => {
2470 anyhow::bail!("MCP connection '{}' was cancelled", self.name)
2471 }
2472 result = self.transport.recv() => result,
2473 }
2474 },
2475 )
2476 .await
2477 {
2478 Ok(result) => result.inspect_err(|_e| {
2479 self.state = ConnectionState::Disconnected;
2480 })?,
2481 Err(_) => {
2482 self.state = ConnectionState::Disconnected;
2483 anyhow::bail!(
2484 "Timed out waiting for MCP JSON-RPC response from server '{}' after {}s",
2485 self.name,
2486 self.read_timeout_secs
2487 );
2488 }
2489 };
2490 let value: serde_json::Value = match serde_json::from_slice(&bytes) {
2491 Ok(value) => value,
2492 Err(err) => {
2493 self.state = ConnectionState::Disconnected;
2494 let preview = if self.config.reviewed_plugin.is_some() {
2495 "<server details suppressed for reviewed plugin>".to_string()
2496 } else {
2497 invalid_json_preview(&bytes)
2498 };
2499 return Err(err).with_context(|| {
2500 format!(
2501 "Invalid MCP JSON-RPC message from server '{}': {}",
2502 self.name, preview
2503 )
2504 });
2505 }
2506 };
2507
2508 // Check if this is a response with the expected id. We emit
2509 // string IDs because some MCP gateways reject numeric JSON-RPC
2510 // IDs, but accept numeric echoes for compatibility with older
2511 // servers and tests.
2512 if response_id_matches(value.get("id"), &expected_id) {
2513 if let Some(error) = value.get("error")
2514 && is_mcp_stale_session_body(&error.to_string())
2515 {
2516 anyhow::bail!("MCP session expired: {error}");
2517 }
2518 return Ok(value);
2519 }
2520 // Skip notifications (no id) and responses with different ids
2521 }
2522 }
2523
2524 /// Gracefully close the connection
2525 #[allow(dead_code)] // Public API for MCP consumers
2526 pub fn close(&mut self) {
2527 self.cancel_token.cancel();
2528 self.state = ConnectionState::Disconnected;
2529 }
2530
2531 fn catalog_authorized(&self) -> bool {
2532 self.config
2533 .reviewed_plugin
2534 .as_ref()
2535 .is_none_or(ReviewedPluginMcpSource::catalog_is_current)
2536 }
2537
2538 async fn finish_guarded_error<T>(&mut self, error: anyhow::Error) -> Result<T> {
2539 let reason = self
2540 .authority_revocation_reason
2541 .lock()
2542 .ok()
2543 .and_then(|reason| reason.clone());
2544 if let Some(reason) = reason {
2545 self.transport.shutdown().await;
2546 self.state = ConnectionState::Disconnected;
2547 anyhow::bail!(
2548 "MCP operation on plugin server '{}' was cancelled after authority changed: {reason}",
2549 self.name
2550 );
2551 }
2552 Err(error)
2553 }
2554 }
2555
2556 /// Apply the ambient proxy policy for MCP HTTP transports.
2557 ///
2558 /// User-authored MCP configuration keeps the long-standing corporate-proxy
2559 /// behavior. Reviewed plugin bundles deliberately do not: proxy URLs can carry
2560 /// credentials and proxy processes can observe request metadata, neither of
2561 /// which is part of the v1 reviewed remote authority. Return before consulting
2562 /// the environment so even reading ambient proxy credentials is impossible on
2563 /// that path, and call `no_proxy` explicitly to keep this invariant stable if
2564 /// reqwest's defaults change.
2565 fn configure_mcp_proxy<F>(
2566 mut client_builder: reqwest::ClientBuilder,
2567 reviewed_plugin: bool,
2568 mut read_environment: F,
2569 ) -> reqwest::ClientBuilder
2570 where
2571 F: FnMut(&str) -> std::result::Result<String, std::env::VarError>,
2572 {
2573 if reviewed_plugin {
2574 return client_builder.no_proxy();
2575 }
2576
2577 let env_proxy_url = read_environment("HTTPS_PROXY")
2578 .or_else(|_| read_environment("https_proxy"))
2579 .or_else(|_| read_environment("HTTP_PROXY"))
2580 .or_else(|_| read_environment("http_proxy"))
2581 .ok()
2582 .filter(|s| !s.trim().is_empty());
2583 if let Some(proxy_url) = env_proxy_url {
2584 match reqwest::Proxy::all(&proxy_url) {
2585 Ok(proxy) => {
2586 let no_proxy = read_environment("NO_PROXY")
2587 .or_else(|_| read_environment("no_proxy"))
2588 .ok()
2589 .and_then(|value| reqwest::NoProxy::from_string(&value));
2590 let proxy = proxy.no_proxy(no_proxy);
2591 client_builder = client_builder.proxy(proxy);
2592 }
2593 Err(err) => {
2594 // Redact userinfo (the `username[:password]@…`
2595 // portion of the URL) before logging so an
2596 // HTTPS_PROXY that embeds credentials
2597 // (common in corporate setups) doesn't leak the
2598 // password to the on-disk `~/.deepseek/logs/`.
2599 let proxy_redacted = redact_proxy_userinfo(&proxy_url);
2600 tracing::warn!(
2601 target: "mcp",
2602 ?err,
2603 proxy = %proxy_redacted,
2604 "ignoring malformed HTTP(S)_PROXY env var; MCP connection will bypass proxy"
2605 );
2606 }
2607 }
2608 }
2609 client_builder
2610 }
2611
2612 impl Drop for McpConnection {
2613 fn drop(&mut self) {
2614 self.cancel_token.cancel();
2615 if let Some(watch) = self.authority_watch.take() {
2616 watch.abort();
2617 }
2618 }
2619 }
2620
2621 // === McpPool - Connection Pool Management ===
2622
2623 #[derive(Debug, Clone)]
2624 struct McpToolRoute {
2625 server_name: String,
2626 tool_name: String,
2627 catalog_generation: u64,
2628 plugin_authority: Option<crate::plugins::types::PluginAuthority>,
2629 }
2630
2631 /// Pool of MCP connections for reuse
2632 pub struct McpPool {
2633 connections: HashMap<String, McpConnection>,
2634 config: McpConfig,
2635 network_policy: Option<NetworkPolicyDecider>,
2636 /// Source paths the config was loaded from. Empty for pools constructed
2637 /// directly via `new` (tests, ad-hoc snapshots). Workspace-aware pools
2638 /// track both global and project-level MCP config paths so lazy reload sees
2639 /// either file appear or change.
2640 config_sources: Vec<PathBuf>,
2641 workspace: Option<PathBuf>,
2642 plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
2643 /// 64-bit content hash of the active config (`hash_mcp_config`). Compared
2644 /// against the freshly-loaded config after an mtime change to skip
2645 /// reloading when the file was merely touched.
2646 config_hash: u64,
2647 /// Monotonic identity for the exact config/plugin catalog generation that
2648 /// advertised a callable MCP item. Resolution captures this value and the
2649 /// call boundary rejects any intervening lazy reload or dynamic mutation.
2650 catalog_generation: AtomicU64,
2651 /// Most recently observed mtime for `config_sources`.
2652 last_mtimes: Vec<Option<std::time::SystemTime>>,
2653 /// Dynamically added MCP servers (from tool calls at runtime).
2654 /// These are not persisted to disk and live for the process lifetime.
2655 pub(crate) dynamic_servers: Arc<RwLock<HashMap<String, McpServerConfig>>>,
2656 }
2657
2658 impl McpPool {
2659 /// Create a new pool with the given configuration
2660 pub fn new(config: McpConfig) -> Self {
2661 let config_hash = hash_mcp_config(&config);
2662 Self {
2663 connections: HashMap::new(),
2664 config,
2665 network_policy: None,
2666 config_sources: Vec::new(),
2667 workspace: None,
2668 plugin_registry: None,
2669 config_hash,
2670 catalog_generation: AtomicU64::new(1),
2671 last_mtimes: Vec::new(),
2672 dynamic_servers: Arc::new(RwLock::new(HashMap::new())),
2673 }
2674 }
2675
2676 /// Create a pool from a configuration file path.
2677 #[cfg(test)]
2678 pub fn from_config_path(path: &std::path::Path) -> Result<Self> {
2679 let config = load_config(path)?;
2680 let mut pool = Self::new(config);
2681 pool.config_sources = vec![path.to_path_buf()];
2682 pool.last_mtimes = vec![mcp_config_mtime(path)];
2683 Ok(pool)
2684 }
2685
2686 /// Create a pool from global MCP config plus workspace-local
2687 /// `.codewhale/mcp.json`. Project servers override same-name global
2688 /// servers and default stdio `cwd` to the workspace root.
2689 #[cfg(test)]
2690 pub fn from_config_path_with_workspace(
2691 path: &std::path::Path,
2692 workspace: &Path,
2693 ) -> Result<Self> {
2694 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace));
2695 Self::from_config_path_with_workspace_and_plugins(path, workspace, plugins)
2696 }
2697
2698 pub fn from_config_path_with_workspace_and_plugins(
2699 path: &std::path::Path,
2700 workspace: &Path,
2701 plugins: Arc<crate::plugins::PluginRegistry>,
2702 ) -> Result<Self> {
2703 if plugins.workspace() != workspace {
2704 anyhow::bail!("plugin registry workspace does not match MCP pool workspace");
2705 }
2706 let config = load_config_with_workspace_and_plugins(path, workspace, plugins.as_ref())?;
2707 let workspace = checked_workspace_path(workspace)?;
2708 let mut pool = Self::new(config);
2709 pool.config_sources = vec![
2710 path.to_path_buf(),
2711 checked_workspace_mcp_config_path(&workspace)?,
2712 ];
2713 pool.config_sources
2714 .extend(crate::config::workspace_trust_config_candidate_paths());
2715 pool.last_mtimes = pool
2716 .config_sources
2717 .iter()
2718 .map(|source| mcp_config_mtime(source))
2719 .collect();
2720 pool.workspace = Some(workspace);
2721 pool.plugin_registry = Some(plugins);
2722 Ok(pool)
2723 }
2724
2725 /// Construct a source-aware empty pool after the initial config load
2726 /// failed. Keeping the source paths means a later edit or explicit
2727 /// `/mcp reload` can recover in-process instead of pinning the session to
2728 /// an ad-hoc pool that has no files to re-read.
2729 pub(crate) fn empty_with_workspace_config_sources(
2730 path: &std::path::Path,
2731 workspace: &Path,
2732 plugins: Arc<crate::plugins::PluginRegistry>,
2733 ) -> Result<Self> {
2734 validate_mcp_config_path(path)?;
2735 if plugins.workspace() != workspace {
2736 anyhow::bail!("plugin registry workspace does not match MCP pool workspace");
2737 }
2738 let workspace = checked_workspace_path(workspace)?;
2739 let mut pool = Self::new(McpConfig::default());
2740 pool.config_sources = vec![
2741 path.to_path_buf(),
2742 checked_workspace_mcp_config_path(&workspace)?,
2743 ];
2744 pool.config_sources
2745 .extend(crate::config::workspace_trust_config_candidate_paths());
2746 pool.last_mtimes = pool
2747 .config_sources
2748 .iter()
2749 .map(|source| mcp_config_mtime(source))
2750 .collect();
2751 pool.workspace = Some(workspace);
2752 pool.plugin_registry = Some(plugins);
2753 Ok(pool)
2754 }
2755
2756 /// Attach a per-domain network policy (#135). When set, HTTP/SSE
2757 /// transports are gated through it; STDIO transports are unaffected.
2758 pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
2759 self.network_policy = Some(policy);
2760 self
2761 }
2762
2763 fn drop_connection(&mut self, server_name: &str, reason: &str) {
2764 if self.connections.remove(server_name).is_some() {
2765 tracing::debug!(
2766 target: "mcp",
2767 server = %server_name,
2768 reason = %reason,
2769 "dropped MCP connection"
2770 );
2771 }
2772 }
2773
2774 fn drop_all_connections(&mut self, reason: &str) {
2775 if self.connections.is_empty() {
2776 return;
2777 }
2778 let count = self.connections.len();
2779 tracing::debug!(
2780 target: "mcp",
2781 count,
2782 reason = %reason,
2783 "dropping MCP connections"
2784 );
2785 self.connections.clear();
2786 }
2787
2788 /// If the source config file's mtime has changed since the last check,
2789 /// re-read it and (only when the content hash also changed) drop all
2790 /// existing connections so the next `get_or_connect` reattaches under
2791 /// the new config. No-op when the pool was constructed via [`McpPool::new`]
2792 /// (no source path), when stat fails, or when the file content is
2793 /// byte-identical to what we last loaded. Returns `Ok(true)` if any
2794 /// connections were dropped, `Ok(false)` otherwise.
2795 ///
2796 /// This is the lazy half of the auto-reload story for #1267: instead of a
2797 /// long-lived file watcher, the next tool invocation pays a single `stat`
2798 /// call (and only re-reads the file when the mtime moved). On networked
2799 /// or remote filesystems where mtime granularity is poor, the hash
2800 /// compare keeps us from churning connections on every check.
2801 fn reload_from_config_sources(&mut self, force: bool) -> Result<bool> {
2802 if self.config_sources.is_empty() {
2803 if force {
2804 anyhow::bail!("MCP pool has no configuration source to reload");
2805 }
2806 return Ok(false);
2807 }
2808 let current_mtimes: Vec<_> = self
2809 .config_sources
2810 .iter()
2811 .map(|path| mcp_config_mtime(path))
2812 .collect();
2813 if !force && current_mtimes == self.last_mtimes {
2814 return Ok(false);
2815 }
2816 // An mtime moved, or the user explicitly requested a reload: re-read
2817 // the complete global + workspace + plugin-backed config.
2818 let primary = self
2819 .config_sources
2820 .first()
2821 .context("MCP config source list unexpectedly empty")?;
2822 let new_config = if let Some(workspace) = self.workspace.as_deref() {
2823 match self.plugin_registry.as_deref() {
2824 Some(plugins) => {
2825 load_config_with_workspace_and_plugins(primary, workspace, plugins)?
2826 }
2827 None => load_config_with_workspace(primary, workspace)?,
2828 }
2829 } else {
2830 load_config(primary)?
2831 };
2832 let new_hash = hash_mcp_config(&new_config);
2833 // Always advance mtimes so a touched-but-unchanged file doesn't
2834 // make us re-read on every subsequent call.
2835 self.last_mtimes = current_mtimes;
2836 if !force && new_hash == self.config_hash {
2837 return Ok(false);
2838 }
2839 // A real content change, or an explicit reload, invalidates every
2840 // advertised route and live transport. The latter matters when OAuth
2841 // credentials changed without changing the config bytes.
2842 self.drop_all_connections(if force {
2843 "explicit config reload"
2844 } else {
2845 "config reload"
2846 });
2847 self.config = new_config;
2848 self.config_hash = new_hash;
2849 self.catalog_generation.fetch_add(1, Ordering::SeqCst);
2850 Ok(true)
2851 }
2852
2853 pub async fn reload_if_config_changed(&mut self) -> Result<bool> {
2854 self.reload_from_config_sources(false)
2855 }
2856
2857 /// Force a source re-read, invalidate all advertised routes, reconnect
2858 /// enabled servers, and return per-server connection errors. Dynamic
2859 /// in-memory servers remain registered because this mutates the existing
2860 /// pool rather than replacing it.
2861 pub async fn reload_and_connect_all(&mut self) -> Result<Vec<(String, anyhow::Error)>> {
2862 self.reload_from_config_sources(true)?;
2863 Ok(self.connect_all().await)
2864 }
2865
2866 /// Switch the global config source transactionally, preserving this
2867 /// shared pool (and its dynamic runtime servers) for parent and sub-agent
2868 /// holders. A malformed replacement leaves the current config,
2869 /// connections, and source paths unchanged.
2870 pub(crate) async fn switch_workspace_config_source_and_connect_all(
2871 &mut self,
2872 path: &Path,
2873 workspace: &Path,
2874 plugins: Arc<crate::plugins::PluginRegistry>,
2875 ) -> Result<Vec<(String, anyhow::Error)>> {
2876 validate_mcp_config_path(path)?;
2877 if plugins.workspace() != workspace {
2878 anyhow::bail!("plugin registry workspace does not match MCP pool workspace");
2879 }
2880 let workspace = checked_workspace_path(workspace)?;
2881 let new_config =
2882 load_config_with_workspace_and_plugins(path, &workspace, plugins.as_ref())?;
2883 let mut new_sources = vec![
2884 path.to_path_buf(),
2885 checked_workspace_mcp_config_path(&workspace)?,
2886 ];
2887 new_sources.extend(crate::config::workspace_trust_config_candidate_paths());
2888 let new_mtimes = new_sources
2889 .iter()
2890 .map(|source| mcp_config_mtime(source))
2891 .collect();
2892
2893 self.drop_all_connections("config source switch");
2894 self.config_hash = hash_mcp_config(&new_config);
2895 self.config = new_config;
2896 self.config_sources = new_sources;
2897 self.last_mtimes = new_mtimes;
2898 self.workspace = Some(workspace);
2899 self.plugin_registry = Some(plugins);
2900 self.catalog_generation.fetch_add(1, Ordering::SeqCst);
2901 Ok(self.connect_all().await)
2902 }
2903
2904 /// Get or create a connection to a server
2905 pub async fn get_or_connect(&mut self, server_name: &str) -> Result<&mut McpConnection> {
2906 // Lazy auto-reload (#1267 part 2): cheap mtime-then-hash check before
2907 // each connection lookup. Transient FS errors are logged but not
2908 // propagated so a brief hiccup can't take down the whole tool dispatch.
2909 if let Err(e) = self.reload_if_config_changed().await {
2910 tracing::warn!("MCP config reload check failed: {e:#}");
2911 }
2912
2913 let plugin_source = self
2914 .connections
2915 .get(server_name)
2916 .and_then(|connection| connection.config().reviewed_plugin.clone())
2917 .or_else(|| {
2918 self.config
2919 .servers
2920 .get(server_name)
2921 .and_then(|config| config.reviewed_plugin.clone())
2922 });
2923 if let Some(source) = plugin_source
2924 && let Err(error) = source.validate_before_use(server_name, "use")
2925 {
2926 self.drop_connection(server_name, "plugin authority revoked or changed");
2927 return Err(error);
2928 }
2929
2930 let is_ready = self
2931 .connections
2932 .get(server_name)
2933 .map(|conn| conn.is_ready())
2934 .unwrap_or(false);
2935 if is_ready {
2936 return self
2937 .connections
2938 .get_mut(server_name)
2939 .ok_or_else(|| anyhow::anyhow!("MCP connection disappeared for {server_name}"));
2940 }
2941
2942 self.drop_connection(server_name, "reconnect");
2943
2944 // Check static config first, then dynamic servers
2945 let server_config = self
2946 .config
2947 .servers
2948 .get(server_name)
2949 .cloned()
2950 .or_else(|| self.dynamic_servers.read().get(server_name).cloned())
2951 .ok_or_else(|| anyhow::anyhow!("Failed to find MCP server: {server_name}"))?;
2952
2953 if !server_config.is_enabled() {
2954 anyhow::bail!("Failed to connect MCP server '{server_name}': server is disabled");
2955 }
2956
2957 let mut connection = McpConnection::connect_with_policy(
2958 server_name.to_string(),
2959 server_config,
2960 &self.config.timeouts,
2961 self.network_policy.as_ref(),
2962 )
2963 .await?;
2964 connection.catalog_generation = self.catalog_generation.load(Ordering::SeqCst);
2965
2966 self.connections.insert(server_name.to_string(), connection);
2967 self.connections
2968 .get_mut(server_name)
2969 .ok_or_else(|| anyhow::anyhow!("Failed to store MCP connection for {server_name}"))
2970 }
2971
2972 /// Connect to all enabled servers, returning errors for failed connections
2973 pub async fn connect_all(&mut self) -> Vec<(String, anyhow::Error)> {
2974 let mut errors = Vec::new();
2975 // Reload before taking the configured-name snapshot. Previously the
2976 // first call after adding a server captured the old names, then only
2977 // noticed the config change inside `get_or_connect`, delaying the new
2978 // server until a second turn.
2979 if let Err(err) = self.reload_if_config_changed().await {
2980 errors.push(("configuration".to_string(), err));
2981 return errors;
2982 }
2983 let names: Vec<String> = self
2984 .config
2985 .servers
2986 .keys()
2987 .filter(|n| self.config.servers[*n].is_enabled())
2988 .cloned()
2989 .collect();
2990
2991 for name in names {
2992 if let Err(e) = self.get_or_connect(&name).await {
2993 errors.push((name, e));
2994 }
2995 }
2996
2997 for (name, server_cfg) in &self.config.servers {
2998 if server_cfg.required
2999 && server_cfg.is_enabled()
3000 && !self
3001 .connections
3002 .get(name)
3003 .is_some_and(McpConnection::is_ready)
3004 {
3005 errors.push((
3006 name.clone(),
3007 anyhow::anyhow!("required MCP server failed to initialize"),
3008 ));
3009 }
3010 }
3011
3012 errors
3013 }
3014
3015 /// The single definition of an MCP tool's model-facing name.
3016 ///
3017 /// [`Self::all_tools`] (which builds the model catalog) and
3018 /// [`Self::resolved_tool_servers`] (which tells tool inspection which
3019 /// server owns a name) both call this, so a human-facing server
3020 /// attribution can never drift from the name the model actually received.
3021 #[must_use]
3022 pub fn mcp_model_tool_name(server: &str, tool: &str) -> String {
3023 format!("mcp_{server}_{tool}")
3024 }
3025
3026 /// Fold `(server, tool)` pairs into `model name -> owning server`.
3027 ///
3028 /// Mirrors [`Self::all_tools`]' ambiguity rule: when two servers produce
3029 /// the same model name, the name is dropped entirely rather than
3030 /// attributed to an arbitrary winner. Callers then report it as unknown.
3031 #[must_use]
3032 pub fn resolve_tool_server_map<'a>(
3033 pairs: impl Iterator<Item = (&'a str, &'a str)>,
3034 ) -> std::collections::BTreeMap<String, String> {
3035 let mut resolved: std::collections::BTreeMap<String, Option<String>> =
3036 std::collections::BTreeMap::new();
3037 for (server, tool) in pairs {
3038 match resolved.entry(Self::mcp_model_tool_name(server, tool)) {
3039 std::collections::btree_map::Entry::Vacant(entry) => {
3040 entry.insert(Some(server.to_string()));
3041 }
3042 std::collections::btree_map::Entry::Occupied(mut entry) => {
3043 entry.insert(None);
3044 }
3045 }
3046 }
3047 resolved
3048 .into_iter()
3049 .filter_map(|(name, server)| server.map(|server| (name, server)))
3050 .collect()
3051 }
3052
3053 /// Model tool name -> owning server name, for the tools this pool actually
3054 /// resolved. Names the pool did not resolve are simply absent, so callers
3055 /// report them as unknown instead of parsing `mcp_{server}_{tool}` (a
3056 /// server name may itself contain `_`, so that split is a guess).
3057 ///
3058 /// Read-only projection used by tool inspection; it never connects or
3059 /// executes.
3060 #[must_use]
3061 pub fn resolved_tool_servers(&self) -> std::collections::BTreeMap<String, String> {
3062 Self::resolve_tool_server_map(self.connections.iter().flat_map(|(server, conn)| {
3063 let authorized = conn.catalog_authorized();
3064 conn.tools().iter().filter_map(move |tool| {
3065 (authorized && conn.config().is_tool_enabled(&tool.name))
3066 .then_some((server.as_str(), tool.name.as_str()))
3067 })
3068 }))
3069 }
3070
3071 /// Get all discovered tools with server-prefixed names
3072 pub fn all_tools(&self) -> Vec<(String, &McpTool)> {
3073 let mut by_name: std::collections::BTreeMap<String, Option<&McpTool>> =
3074 std::collections::BTreeMap::new();
3075 for (server, conn) in &self.connections {
3076 if !conn.catalog_authorized() {
3077 continue;
3078 }
3079 for tool in conn.tools() {
3080 if !conn.config().is_tool_enabled(&tool.name) {
3081 continue;
3082 }
3083 let name = Self::mcp_model_tool_name(server, &tool.name);
3084 match by_name.entry(name.clone()) {
3085 std::collections::btree_map::Entry::Vacant(entry) => {
3086 entry.insert(Some(tool));
3087 }
3088 std::collections::btree_map::Entry::Occupied(mut entry) => {
3089 tracing::warn!(
3090 target: "mcp",
3091 model_tool = %name,
3092 "hiding ambiguous MCP model tool name"
3093 );
3094 entry.insert(None);
3095 }
3096 }
3097 }
3098 }
3099 by_name
3100 .into_iter()
3101 .filter_map(|(name, tool)| tool.map(|tool| (name, tool)))
3102 .collect()
3103 }
3104
3105 /// Get all discovered resources with server-prefixed names
3106 pub fn all_resources(&self) -> Vec<(String, &McpResource)> {
3107 let mut resources = Vec::new();
3108 for (server, conn) in &self.connections {
3109 if !conn.catalog_authorized() {
3110 continue;
3111 }
3112 for resource in conn.resources() {
3113 // Format: mcp_{server}_{resource_name}
3114 // Note: resource names might contain spaces, we should probably slugify them
3115 let safe_name = resource.name.replace(' ', "_").to_lowercase();
3116 resources.push((format!("mcp_{server}_{safe_name}"), resource));
3117 }
3118 }
3119 resources
3120 }
3121
3122 /// Get all discovered resource templates with server-prefixed names
3123 #[allow(dead_code)] // Public API for MCP resource discovery
3124 pub fn all_resource_templates(&self) -> Vec<(String, &McpResourceTemplate)> {
3125 let mut templates = Vec::new();
3126 for (server, conn) in &self.connections {
3127 if !conn.catalog_authorized() {
3128 continue;
3129 }
3130 for template in conn.resource_templates() {
3131 let safe_name = template.name.replace(' ', "_").to_lowercase();
3132 templates.push((format!("mcp_{server}_{safe_name}"), template));
3133 }
3134 }
3135 templates
3136 }
3137
3138 async fn list_resources(&mut self, server: Option<String>) -> Result<Vec<serde_json::Value>> {
3139 if let Some(server_name) = server {
3140 let conn = self.get_or_connect(&server_name).await?;
3141 let resources = conn
3142 .resources()
3143 .iter()
3144 .map(|resource| {
3145 serde_json::json!({
3146 "server": server_name.clone(),
3147 "uri": resource.uri,
3148 "name": resource.name,
3149 "description": resource.description,
3150 "mime_type": resource.mime_type,
3151 })
3152 })
3153 .collect();
3154 return Ok(resources);
3155 }
3156
3157 let mut items = Vec::new();
3158 let errors = self.connect_all().await;
3159 for (server, err) in errors {
3160 tracing::warn!("Failed to connect MCP server '{server}' for resources: {err:#}");
3161 if oauth::error_looks_auth_required(&err) {
3162 items.push(Self::mcp_auth_required_error_item(&server));
3163 }
3164 }
3165 for (server, conn) in &self.connections {
3166 if !conn.catalog_authorized() {
3167 continue;
3168 }
3169 for resource in conn.resources() {
3170 items.push(serde_json::json!({
3171 "server": server,
3172 "uri": resource.uri,
3173 "name": resource.name,
3174 "description": resource.description,
3175 "mime_type": resource.mime_type,
3176 }));
3177 }
3178 }
3179 Ok(items)
3180 }
3181
3182 async fn list_resource_templates(
3183 &mut self,
3184 server: Option<String>,
3185 ) -> Result<Vec<serde_json::Value>> {
3186 if let Some(server_name) = server {
3187 let conn = self.get_or_connect(&server_name).await?;
3188 let templates = conn
3189 .resource_templates()
3190 .iter()
3191 .map(|template| {
3192 serde_json::json!({
3193 "server": server_name.clone(),
3194 "uri_template": template.uri_template,
3195 "name": template.name,
3196 "description": template.description,
3197 "mime_type": template.mime_type,
3198 })
3199 })
3200 .collect();
3201 return Ok(templates);
3202 }
3203
3204 let mut items = Vec::new();
3205 let errors = self.connect_all().await;
3206 for (server, err) in errors {
3207 tracing::warn!(
3208 "Failed to connect MCP server '{server}' for resource templates: {err:#}"
3209 );
3210 if oauth::error_looks_auth_required(&err) {
3211 items.push(Self::mcp_auth_required_error_item(&server));
3212 }
3213 }
3214 for (server, conn) in &self.connections {
3215 if !conn.catalog_authorized() {
3216 continue;
3217 }
3218 for template in conn.resource_templates() {
3219 items.push(serde_json::json!({
3220 "server": server,
3221 "uri_template": template.uri_template,
3222 "name": template.name,
3223 "description": template.description,
3224 "mime_type": template.mime_type,
3225 }));
3226 }
3227 }
3228 Ok(items)
3229 }
3230
3231 fn mcp_auth_required_error_item(server: &str) -> serde_json::Value {
3232 serde_json::json!({
3233 "error": "authentication_required",
3234 "server": server,
3235 "message": oauth::auth_required_login_hint(server),
3236 })
3237 }
3238
3239 /// Get all discovered prompts with server-prefixed names
3240 pub fn all_prompts(&self) -> Vec<(String, &McpPrompt)> {
3241 let mut prompts = Vec::new();
3242 for (server, conn) in &self.connections {
3243 if !conn.catalog_authorized() {
3244 continue;
3245 }
3246 for prompt in conn.prompts() {
3247 // Format: mcp_{server}_{prompt}
3248 prompts.push((format!("mcp_{}_{}", server, prompt.name), prompt));
3249 }
3250 }
3251 prompts
3252 }
3253
3254 /// Read a resource from a specific server
3255 pub async fn read_resource(
3256 &mut self,
3257 server_name: &str,
3258 uri: &str,
3259 ) -> Result<serde_json::Value> {
3260 let global_timeouts = self.config.timeouts;
3261 let conn = self.get_or_connect(server_name).await?;
3262 let advertised_literal = conn.resources().iter().any(|resource| resource.uri == uri);
3263 let advertised_template = conn
3264 .resource_templates()
3265 .iter()
3266 .any(|template| resource_uri_matches_template(uri, &template.uri_template));
3267 if !advertised_literal && !advertised_template {
3268 anyhow::bail!("MCP resource URI '{uri}' was not advertised by server '{server_name}'");
3269 }
3270 let timeout = conn.config().effective_read_timeout(&global_timeouts);
3271 conn.read_resource(uri, timeout).await
3272 }
3273
3274 /// Get a prompt from a specific server
3275 pub async fn get_prompt(
3276 &mut self,
3277 server_name: &str,
3278 prompt_name: &str,
3279 arguments: serde_json::Value,
3280 ) -> Result<serde_json::Value> {
3281 let global_timeouts = self.config.timeouts;
3282 let conn = self.get_or_connect(server_name).await?;
3283 if !conn
3284 .prompts()
3285 .iter()
3286 .any(|prompt| prompt.name == prompt_name)
3287 {
3288 anyhow::bail!(
3289 "MCP prompt '{prompt_name}' was not advertised by server '{server_name}'"
3290 );
3291 }
3292 let timeout = conn.config().effective_execute_timeout(&global_timeouts);
3293 conn.get_prompt(prompt_name, arguments, timeout).await
3294 }
3295
3296 /// Parse a prefixed name into (server_name, tool_name)
3297 pub(crate) fn parse_prefixed_name(&self, prefixed_name: &str) -> Result<(String, String)> {
3298 let Some(rest) = prefixed_name.strip_prefix("mcp_") else {
3299 anyhow::bail!("Invalid MCP tool name: {prefixed_name}");
3300 };
3301
3302 let mut matched: Option<(String, String)> = None;
3303 for (server, connection) in &self.connections {
3304 if !connection.catalog_authorized() {
3305 continue;
3306 }
3307 for tool in connection.tools() {
3308 if !connection.config().is_tool_enabled(&tool.name)
3309 || format!("{server}_{}", tool.name) != rest
3310 {
3311 continue;
3312 }
3313 if matched.is_some() {
3314 anyhow::bail!(
3315 "Ambiguous MCP tool name '{prefixed_name}' matches more than one server/tool authority"
3316 );
3317 }
3318 matched = Some((server.clone(), tool.name.clone()));
3319 }
3320 }
3321 if let Some(matched) = matched {
3322 return Ok(matched);
3323 }
3324
3325 Err(anyhow::anyhow!("Unknown MCP tool name: {prefixed_name}"))
3326 }
3327
3328 /// Resolve an MCP tool through an exact advertised catalog. A configured
3329 /// but lazy server may be connected and asked for `tools/list`; the
3330 /// requested suffix is never treated as authority on its own.
3331 async fn resolve_advertised_tool(&mut self, prefixed_name: &str) -> Result<McpToolRoute> {
3332 if let Ok((server_name, tool_name)) = self.parse_prefixed_name(prefixed_name) {
3333 return self.capture_tool_route(server_name, tool_name);
3334 }
3335 let Some(rest) = prefixed_name.strip_prefix("mcp_") else {
3336 anyhow::bail!("Invalid MCP tool name: {prefixed_name}");
3337 };
3338 let mut candidates = {
3339 let dynamic = self.dynamic_servers.read();
3340 self.config
3341 .servers
3342 .iter()
3343 .filter_map(|(name, config)| {
3344 (config.is_enabled()
3345 && rest
3346 .strip_prefix(name)
3347 .is_some_and(|suffix| suffix.starts_with('_')))
3348 .then_some(name.clone())
3349 })
3350 .chain(dynamic.iter().filter_map(|(name, config)| {
3351 (config.is_enabled()
3352 && rest
3353 .strip_prefix(name)
3354 .is_some_and(|suffix| suffix.starts_with('_')))
3355 .then_some(name.clone())
3356 }))
3357 .collect::<Vec<_>>()
3358 };
3359 candidates.sort();
3360 candidates.dedup();
3361 for server in candidates {
3362 // Connecting and catalog discovery are the only lazy side effects.
3363 // A guessed method is never sent to the transport.
3364 let _ = self.get_or_connect(&server).await?;
3365 }
3366 let (server_name, tool_name) = self.parse_prefixed_name(prefixed_name)?;
3367 self.capture_tool_route(server_name, tool_name)
3368 }
3369
3370 fn capture_tool_route(&self, server_name: String, tool_name: String) -> Result<McpToolRoute> {
3371 let connection = self
3372 .connections
3373 .get(&server_name)
3374 .context("advertised MCP connection disappeared during resolution")?;
3375 let plugin_authority = connection
3376 .config()
3377 .reviewed_plugin
3378 .as_ref()
3379 .map(|source| source.authority.clone());
3380 Ok(McpToolRoute {
3381 server_name,
3382 tool_name,
3383 catalog_generation: connection.catalog_generation,
3384 plugin_authority,
3385 })
3386 }
3387
3388 /// Convert discovered tools to API Tool format
3389 pub fn to_api_tools(&self) -> Vec<crate::models::Tool> {
3390 let mut api_tools = Vec::new();
3391
3392 // Add regular tools
3393 for (name, tool) in self.all_tools() {
3394 api_tools.push(crate::models::Tool {
3395 tool_type: None,
3396 name,
3397 description: tool.description.clone().unwrap_or_default(),
3398 input_schema: tool.input_schema.clone(),
3399 allowed_callers: Some(vec!["direct".to_string()]),
3400 defer_loading: Some(false),
3401 input_examples: None,
3402 strict: None,
3403 cache_control: None,
3404 });
3405 }
3406
3407 // Only advertise each resource-listing meta-tool when the servers actually
3408 // expose the corresponding kind. Previously both were injected whenever any
3409 // MCP server was configured, so tools-only servers left the model with
3410 // meta-tools that can only ever return empty results — a wasted tool slot
3411 // and prompt tokens. Gate each on its own non-empty collection, mirroring
3412 // the `mcp_read_resource` guard below (`!resources.is_empty()`).
3413 if !self.all_resources().is_empty() {
3414 api_tools.push(crate::models::Tool {
3415 tool_type: None,
3416 name: "list_mcp_resources".to_string(),
3417 description: "List available MCP resources across servers (optionally filtered by server).".to_string(),
3418 input_schema: serde_json::json!({
3419 "type": "object",
3420 "properties": {
3421 "server": { "type": "string", "description": "Optional MCP server name to filter by" }
3422 }
3423 }),
3424 allowed_callers: Some(vec!["direct".to_string()]),
3425 defer_loading: Some(false),
3426 input_examples: None,
3427 strict: None,
3428 cache_control: None,
3429 });
3430 }
3431 if !self.all_resource_templates().is_empty() {
3432 api_tools.push(crate::models::Tool {
3433 tool_type: None,
3434 name: "list_mcp_resource_templates".to_string(),
3435 description: "List available MCP resource templates across servers (optionally filtered by server).".to_string(),
3436 input_schema: serde_json::json!({
3437 "type": "object",
3438 "properties": {
3439 "server": { "type": "string", "description": "Optional MCP server name to filter by" }
3440 }
3441 }),
3442 allowed_callers: Some(vec!["direct".to_string()]),
3443 defer_loading: Some(false),
3444 input_examples: None,
3445 strict: None,
3446 cache_control: None,
3447 });
3448 }
3449
3450 // Add resource reading tools if resources exist
3451 let resources = self.all_resources();
3452 if !resources.is_empty() {
3453 api_tools.push(crate::models::Tool {
3454 tool_type: None,
3455 name: "mcp_read_resource".to_string(),
3456 description: "Read a resource from an MCP server using its URI".to_string(),
3457 input_schema: serde_json::json!({
3458 "type": "object",
3459 "properties": {
3460 "server": { "type": "string", "description": "The name of the MCP server" },
3461 "uri": { "type": "string", "description": "The URI of the resource to read" }
3462 },
3463 "required": ["server", "uri"]
3464 }),
3465 allowed_callers: Some(vec!["direct".to_string()]),
3466 defer_loading: Some(false),
3467 input_examples: None,
3468 strict: None,
3469 cache_control: None,
3470 });
3471 api_tools.push(crate::models::Tool {
3472 tool_type: None,
3473 name: "read_mcp_resource".to_string(),
3474 description: "Alias for mcp_read_resource.".to_string(),
3475 input_schema: serde_json::json!({
3476 "type": "object",
3477 "properties": {
3478 "server": { "type": "string", "description": "The name of the MCP server" },
3479 "uri": { "type": "string", "description": "The URI of the resource to read" }
3480 },
3481 "required": ["server", "uri"]
3482 }),
3483 allowed_callers: Some(vec!["direct".to_string()]),
3484 defer_loading: Some(false),
3485 input_examples: None,
3486 strict: None,
3487 cache_control: None,
3488 });
3489 }
3490
3491 // Add prompt getting tools if prompts exist
3492 let prompts = self.all_prompts();
3493 if !prompts.is_empty() {
3494 api_tools.push(crate::models::Tool {
3495 tool_type: None,
3496 name: "mcp_get_prompt".to_string(),
3497 description: "Get a prompt from an MCP server".to_string(),
3498 input_schema: serde_json::json!({
3499 "type": "object",
3500 "properties": {
3501 "server": { "type": "string", "description": "The name of the MCP server" },
3502 "name": { "type": "string", "description": "The name of the prompt" },
3503 "arguments": {
3504 "type": "object",
3505 "description": "Optional arguments for the prompt",
3506 "additionalProperties": { "type": "string" }
3507 }
3508 },
3509 "required": ["server", "name"]
3510 }),
3511 allowed_callers: Some(vec!["direct".to_string()]),
3512 defer_loading: Some(false),
3513 input_examples: None,
3514 strict: None,
3515 cache_control: None,
3516 });
3517 }
3518
3519 // Sort by name for prefix-cache stability — the tool block sent to
3520 // the model needs to be deterministic across runs (#1319).
3521 api_tools.sort_by(|a, b| a.name.cmp(&b.name));
3522 api_tools
3523 }
3524
3525 /// Call a tool by its prefixed name (mcp_{server}_{tool})
3526 pub async fn call_tool(
3527 &mut self,
3528 prefixed_name: &str,
3529 arguments: serde_json::Value,
3530 ) -> Result<serde_json::Value> {
3531 if prefixed_name == "list_mcp_resources" {
3532 let server = arguments
3533 .get("server")
3534 .and_then(|v| v.as_str())
3535 .map(str::to_string);
3536 let resources = self.list_resources(server).await?;
3537 return Ok(serde_json::json!({ "resources": resources }));
3538 }
3539
3540 if prefixed_name == "list_mcp_resource_templates" {
3541 let server = arguments
3542 .get("server")
3543 .and_then(|v| v.as_str())
3544 .map(str::to_string);
3545 let templates = self.list_resource_templates(server).await?;
3546 return Ok(serde_json::json!({ "templates": templates }));
3547 }
3548
3549 if prefixed_name == "mcp_read_resource" {
3550 let server_name = arguments
3551 .get("server")
3552 .and_then(|v| v.as_str())
3553 .context("Missing 'server' argument")?;
3554 let uri = arguments
3555 .get("uri")
3556 .and_then(|v| v.as_str())
3557 .context("Missing 'uri' argument")?;
3558 return self.read_resource(server_name, uri).await;
3559 }
3560
3561 if prefixed_name == "read_mcp_resource" {
3562 let server_name = arguments
3563 .get("server")
3564 .and_then(|v| v.as_str())
3565 .context("Missing 'server' argument")?;
3566 let uri = arguments
3567 .get("uri")
3568 .and_then(|v| v.as_str())
3569 .context("Missing 'uri' argument")?;
3570 return self.read_resource(server_name, uri).await;
3571 }
3572
3573 if prefixed_name == "mcp_get_prompt" {
3574 let server_name = arguments
3575 .get("server")
3576 .and_then(|v| v.as_str())
3577 .context("Missing 'server' argument")?;
3578 let name = arguments
3579 .get("name")
3580 .and_then(|v| v.as_str())
3581 .context("Missing 'name' argument")?;
3582 let args = arguments
3583 .get("arguments")
3584 .cloned()
3585 .unwrap_or(serde_json::json!({}));
3586 return self.get_prompt(server_name, name, args).await;
3587 }
3588
3589 let route = self.resolve_advertised_tool(prefixed_name).await?;
3590 let server_name = route.server_name.clone();
3591 let tool_name = route.tool_name.clone();
3592 // Copy the global timeouts to avoid borrow conflict
3593 let global_timeouts = self.config.timeouts;
3594 let conn = self.get_or_connect(&server_name).await?;
3595 if conn.catalog_generation != route.catalog_generation {
3596 anyhow::bail!("MCP catalog changed after tool resolution; retry the call");
3597 }
3598 if conn
3599 .config()
3600 .reviewed_plugin
3601 .as_ref()
3602 .map(|source| &source.authority)
3603 != route.plugin_authority.as_ref()
3604 || !conn.config().is_tool_enabled(&tool_name)
3605 || !conn.tools().iter().any(|tool| tool.name == tool_name)
3606 {
3607 anyhow::bail!("MCP tool '{tool_name}' is disabled for server '{server_name}'");
3608 }
3609 let timeout = conn.config().effective_execute_timeout(&global_timeouts);
3610 match conn.call_tool(&tool_name, arguments.clone(), timeout).await {
3611 Ok(result) => Ok(result),
3612 Err(err) if is_mcp_stale_session_error(&err) => {
3613 tracing::debug!(
3614 target: "mcp",
3615 server = server_name,
3616 tool = tool_name,
3617 error = %err,
3618 "retrying MCP tool call after stale session"
3619 );
3620 self.drop_connection(&server_name, "stale session retry");
3621 let conn = self.get_or_connect(&server_name).await?;
3622 if conn.catalog_generation != route.catalog_generation
3623 || conn
3624 .config()
3625 .reviewed_plugin
3626 .as_ref()
3627 .map(|source| &source.authority)
3628 != route.plugin_authority.as_ref()
3629 || !conn.config().is_tool_enabled(&tool_name)
3630 || !conn.tools().iter().any(|tool| tool.name == tool_name)
3631 {
3632 anyhow::bail!("MCP tool '{tool_name}' is disabled for server '{server_name}'");
3633 }
3634 let timeout = conn.config().effective_execute_timeout(&global_timeouts);
3635 conn.call_tool(&tool_name, arguments, timeout).await
3636 }
3637 Err(err) => Err(err),
3638 }
3639 }
3640
3641 /// Get list of configured server names (static + dynamic)
3642 #[allow(dead_code)] // Public API for MCP consumers
3643 pub fn server_names(&self) -> Vec<String> {
3644 let mut names: Vec<String> = self.config.servers.keys().cloned().collect();
3645 let dynamic = self.dynamic_servers.read();
3646 for name in dynamic.keys() {
3647 if !names.contains(name) {
3648 names.push(name.clone());
3649 }
3650 }
3651 names
3652 }
3653
3654 /// Add a runtime server configuration (in-memory only, not persisted).
3655 ///
3656 /// This is used for dynamically started MCP servers from chat context.
3657 /// Stored in `dynamic_servers` so it doesn't interfere with file-based config reload.
3658 ///
3659 /// Returns `Err` if a server with the same name already exists as a static config
3660 /// or a dynamic config. The caller should surface the error to the LLM/user.
3661 pub fn add_runtime_server_config(
3662 &self,
3663 name: String,
3664 config: McpServerConfig,
3665 ) -> Result<(), String> {
3666 if self.config.servers.contains_key(&name) {
3667 return Err(format!(
3668 "MCP server '{}' already exists in the config file. \
3669 Remove it from the config first, or choose a different name.",
3670 name
3671 ));
3672 }
3673 let mut dynamic = self.dynamic_servers.write();
3674 if dynamic.contains_key(&name) {
3675 return Err(format!(
3676 "MCP server '{}' was already started earlier in this session. \
3677 Choose a different name.",
3678 name
3679 ));
3680 }
3681 dynamic.insert(name, config);
3682 self.catalog_generation.fetch_add(1, Ordering::SeqCst);
3683 Ok(())
3684 }
3685
3686 /// Remove an in-memory runtime server after a failed start attempt.
3687 /// This makes dynamic registration transactional: callers may retry the
3688 /// same deterministic name after correcting an argument or install issue.
3689 pub fn remove_runtime_server_config(&mut self, name: &str) {
3690 self.drop_connection(name, "runtime server start rolled back");
3691 if self.dynamic_servers.write().remove(name).is_some() {
3692 self.catalog_generation.fetch_add(1, Ordering::SeqCst);
3693 }
3694 }
3695
3696 /// Get list of connected server names
3697 #[allow(dead_code)] // Public API; the HTTP list endpoint no longer spawns a pool to call it (#3532)
3698 pub fn connected_servers(&self) -> Vec<&str> {
3699 self.connections
3700 .iter()
3701 .filter(|(_, c)| c.is_ready())
3702 .map(|(n, _)| n.as_str())
3703 .collect()
3704 }
3705
3706 /// Names of every *enabled* server this pool would connect on the next
3707 /// turn (static config + dynamic runtime entries).
3708 ///
3709 /// Read-only: unlike [`Self::connect_all`], it neither reloads the config
3710 /// sources nor starts a process. `/preview-request` uses it, together
3711 /// with [`Self::connected_servers`] and
3712 /// [`Self::config_sources_unchanged`], to decide whether the currently
3713 /// connected tool set is *exactly* what the next turn would send — and to
3714 /// report the tool surface as unavailable when it is not (#1004).
3715 pub fn enabled_server_names(&self) -> Vec<String> {
3716 let mut names: Vec<String> = self
3717 .config
3718 .servers
3719 .iter()
3720 .filter(|(_, server)| server.is_enabled())
3721 .map(|(name, _)| name.clone())
3722 .collect();
3723 let dynamic = self.dynamic_servers.read();
3724 for (name, server) in dynamic.iter() {
3725 if server.is_enabled() && !names.contains(name) {
3726 names.push(name.clone());
3727 }
3728 }
3729 names
3730 }
3731
3732 /// Whether every configured MCP source still has the mtime this pool last
3733 /// read, i.e. whether `connect_all` would find anything new.
3734 ///
3735 /// Stats files; never reads, parses, reloads, or drops a connection. A
3736 /// pool with no configured source is trivially unchanged.
3737 pub fn config_sources_unchanged(&self) -> bool {
3738 if self.config_sources.is_empty() {
3739 return true;
3740 }
3741 let current: Vec<_> = self
3742 .config_sources
3743 .iter()
3744 .map(|path| mcp_config_mtime(path))
3745 .collect();
3746 current == self.last_mtimes
3747 }
3748
3749 /// Disconnect all connections
3750 #[allow(dead_code)] // Public API for MCP lifecycle management
3751 pub fn disconnect_all(&mut self) {
3752 self.drop_all_connections("disconnect all");
3753 }
3754
3755 /// Graceful shutdown of every connection in the pool: send SIGTERM to
3756 /// each stdio child and give them a short grace period before drop
3757 /// fires SIGKILL. Whalescale#420.
3758 ///
3759 /// Call from the TUI exit path *before* dropping the pool to give
3760 /// MCP servers a chance to flush state. The fallback Drop on
3761 /// `StdioTransport` still sends SIGTERM if this never runs, so even
3762 /// abnormal exits avoid leaking PIDs without a signal.
3763 #[allow(dead_code)] // Wired in by callers that want graceful shutdown
3764 pub async fn shutdown_all(&mut self) {
3765 let names: Vec<String> = self.connections.keys().cloned().collect();
3766 for name in names {
3767 if let Some(conn) = self.connections.get_mut(&name) {
3768 conn.transport.shutdown().await;
3769 }
3770 }
3771 self.connections.clear();
3772 }
3773
3774 /// Get the underlying configuration
3775 #[allow(dead_code)] // Public API for MCP consumers
3776 pub fn config(&self) -> &McpConfig {
3777 &self.config
3778 }
3779
3780 /// Check if a tool name is an MCP tool
3781 pub fn is_mcp_tool(name: &str) -> bool {
3782 name.starts_with("mcp_")
3783 || matches!(
3784 name,
3785 "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource"
3786 )
3787 }
3788 }
3789
3790 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3791 pub enum McpWriteStatus {
3792 Created,
3793 Overwritten,
3794 SkippedExists,
3795 }
3796
3797 #[derive(Debug, Clone, PartialEq, Eq)]
3798 pub struct McpDiscoveredItem {
3799 pub name: String,
3800 pub model_name: String,
3801 pub description: Option<String>,
3802 }
3803
3804 #[derive(Debug, Clone, PartialEq, Eq)]
3805 pub struct McpServerSnapshot {
3806 pub name: String,
3807 pub enabled: bool,
3808 pub required: bool,
3809 pub transport: String,
3810 pub command_or_url: String,
3811 pub connect_timeout: u64,
3812 pub execute_timeout: u64,
3813 pub read_timeout: u64,
3814 pub connected: bool,
3815 pub error: Option<String>,
3816 pub tools: Vec<McpDiscoveredItem>,
3817 pub resources: Vec<McpDiscoveredItem>,
3818 pub prompts: Vec<McpDiscoveredItem>,
3819 }
3820
3821 #[derive(Debug, Clone, PartialEq, Eq)]
3822 pub struct McpManagerSnapshot {
3823 pub config_path: std::path::PathBuf,
3824 pub config_exists: bool,
3825 pub reload_required: bool,
3826 pub servers: Vec<McpServerSnapshot>,
3827 }
3828
3829 pub fn load_config(path: &Path) -> Result<McpConfig> {
3830 validate_mcp_config_path(path)?;
3831 let Some(contents) = read_mcp_config_file(path)? else {
3832 return Ok(McpConfig::default());
3833 };
3834 serde_json::from_str(&contents).map_err(|_| {
3835 anyhow::anyhow!(
3836 "Failed to parse MCP config {}; file contents were omitted",
3837 codewhale_config::quote_os_path(path)
3838 )
3839 })
3840 }
3841
3842 fn read_mcp_config_file(path: &Path) -> Result<Option<String>> {
3843 let metadata = match fs::symlink_metadata(path) {
3844 Ok(metadata) => metadata,
3845 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3846 Err(err) => {
3847 return Err(err)
3848 .with_context(|| format!("Failed to inspect MCP config {}", path.display()));
3849 }
3850 };
3851 let file_type = metadata.file_type();
3852 if file_type.is_symlink() || !file_type.is_file() {
3853 anyhow::bail!("MCP config path must be a regular file: {}", path.display());
3854 }
3855
3856 let mut file = open_mcp_config_file(path)
3857 .with_context(|| format!("Failed to read MCP config {}", path.display()))?;
3858 let mut contents = String::new();
3859 file.read_to_string(&mut contents)
3860 .with_context(|| format!("Failed to read MCP config {}", path.display()))?;
3861 Ok(Some(contents))
3862 }
3863
3864 #[cfg(unix)]
3865 fn open_mcp_config_file(path: &Path) -> std::io::Result<fs::File> {
3866 use std::os::unix::fs::OpenOptionsExt;
3867
3868 fs::OpenOptions::new()
3869 .read(true)
3870 .custom_flags(libc::O_NOFOLLOW)
3871 .open(path)
3872 }
3873
3874 #[cfg(not(unix))]
3875 fn open_mcp_config_file(path: &Path) -> std::io::Result<fs::File> {
3876 fs::File::open(path)
3877 }
3878
3879 pub fn workspace_mcp_config_path(workspace: &Path) -> PathBuf {
3880 normalize_workspace_path(workspace)
3881 .join(".codewhale")
3882 .join("mcp.json")
3883 }
3884
3885 pub fn load_config_with_workspace(global_path: &Path, workspace: &Path) -> Result<McpConfig> {
3886 let plugins = crate::plugins::PluginRegistry::empty(workspace);
3887 load_config_with_workspace_and_plugins(global_path, workspace, &plugins)
3888 }
3889
3890 pub fn load_config_with_workspace_and_plugins(
3891 global_path: &Path,
3892 workspace: &Path,
3893 plugins: &crate::plugins::PluginRegistry,
3894 ) -> Result<McpConfig> {
3895 let mut merged = load_config(global_path)?;
3896 let workspace = checked_workspace_path(workspace)?;
3897 let project_path = checked_workspace_mcp_config_path(&workspace)?;
3898 if !project_path.exists() || paths_refer_to_same_config(global_path, &project_path) {
3899 return merge_plugin_mcp_servers(merged, plugins);
3900 }
3901 // Workspace-local MCP can spawn stdio servers, so it is only honored after
3902 // the user has trusted this workspace in user-owned config. Do not accept
3903 // project-local legacy trust markers here: a repository could carry those
3904 // files itself and silently reintroduce the project-scope `mcp_config_path`
3905 // risk denied in #417.
3906 if !workspace_allows_project_mcp_config(&workspace) {
3907 return merge_plugin_mcp_servers(merged, plugins);
3908 }
3909
3910 let mut project = load_config(&project_path)?;
3911 for server in project.servers.values_mut() {
3912 if server.command.is_some() && server.url.is_none() {
3913 server.cwd = Some(resolve_project_mcp_cwd(&workspace, server.cwd.as_deref())?);
3914 }
3915 }
3916 merged.servers.extend(project.servers);
3917
3918 merge_plugin_mcp_servers(merged, plugins)
3919 }
3920
3921 fn merge_plugin_mcp_servers(
3922 config: McpConfig,
3923 registry: &crate::plugins::PluginRegistry,
3924 ) -> Result<McpConfig> {
3925 let Some(state_path) = registry.state_path().map(Path::to_path_buf) else {
3926 return Ok(config);
3927 };
3928 let plugins = registry
3929 .active_plugins()
3930 .into_iter()
3931 .filter_map(|plugin| {
3932 plugin
3933 .authority(state_path.clone(), registry.workspace().to_path_buf())
3934 .map(|authority| (plugin.name().to_string(), plugin.clone(), authority))
3935 })
3936 .collect::<Vec<_>>();
3937
3938 let host_environment = registry.host_environment().ok_or_else(|| {
3939 anyhow::anyhow!("active plugin registry is missing its pre-dotenv environment snapshot")
3940 })?;
3941 merge_plugin_mcp_servers_from_plugins_with_environment(config, plugins, host_environment)
3942 }
3943
3944 fn merge_plugin_mcp_servers_from_plugins_with_environment(
3945 mut config: McpConfig,
3946 plugins: impl IntoIterator<
3947 Item = (
3948 String,
3949 crate::plugins::types::LoadedPlugin,
3950 crate::plugins::types::PluginAuthority,
3951 ),
3952 >,
3953 host_environment: Arc<crate::plugins::HostEnvironment>,
3954 ) -> Result<McpConfig> {
3955 for (plugin_name, plugin, authority) in plugins {
3956 // Adapter-level denial keeps headless paths fail-closed even if a
3957 // future caller accidentally passes the full inventory instead of the
3958 // registry's active-only view.
3959 if !plugin.active() {
3960 continue;
3961 }
3962 if crate::plugins::registry::verify_plugin_authority(&authority).is_err() {
3963 tracing::warn!(
3964 target: "mcp",
3965 plugin = %plugin_name,
3966 "plugin bundle changed after review; denying its MCP servers until reload and re-review"
3967 );
3968 continue;
3969 }
3970 if let Some(mcp_servers) = &plugin.manifest.mcp_servers {
3971 let mut mcp_servers = mcp_servers.iter().collect::<Vec<_>>();
3972 mcp_servers.sort_by_key(|(name, _)| *name);
3973 for (server_name, server_config) in mcp_servers {
3974 let qualified_name = qualified_plugin_server_name(&plugin_name, server_name);
3975 if config.servers.contains_key(&qualified_name) {
3976 tracing::warn!(
3977 target: "mcp",
3978 plugin = %plugin_name,
3979 server = %server_name,
3980 qualified_name = %qualified_name,
3981 "explicit MCP configuration keeps precedence over a colliding plugin server"
3982 );
3983 continue;
3984 }
3985 let mut server_config = server_config.clone();
3986
3987 if server_config.command.is_some() && server_config.url.is_none() {
3988 let staged_root = plugin
3989 .staged_root
3990 .as_deref()
3991 .context("active plugin is missing its runtime snapshot")?;
3992 server_config.cwd = Some(resolve_plugin_mcp_cwd(
3993 staged_root,
3994 server_config.cwd.as_deref(),
3995 )?);
3996 freeze_plugin_stdio_paths(&mut server_config, staged_root)?;
3997 }
3998 server_config.reviewed_plugin = Some(ReviewedPluginMcpSource::from_authority(
3999 authority.clone(),
4000 server_config.url.as_deref(),
4001 Arc::clone(&host_environment),
4002 )?);
4003
4004 config.servers.insert(qualified_name, server_config);
4005 }
4006 }
4007 }
4008
4009 Ok(config)
4010 }
4011
4012 #[cfg(test)]
4013 fn merge_plugin_mcp_servers_from_plugins(
4014 config: McpConfig,
4015 plugins: impl IntoIterator<
4016 Item = (
4017 String,
4018 crate::plugins::types::LoadedPlugin,
4019 crate::plugins::types::PluginAuthority,
4020 ),
4021 >,
4022 ) -> Result<McpConfig> {
4023 merge_plugin_mcp_servers_from_plugins_with_environment(
4024 config,
4025 plugins,
4026 Arc::new(crate::plugins::HostEnvironment::capture()),
4027 )
4028 }
4029
4030 fn qualified_plugin_server_name(plugin_name: &str, server_name: &str) -> String {
4031 format!(
4032 "plugin-{}-{}-{}",
4033 plugin_name.len(),
4034 plugin_name,
4035 server_name
4036 )
4037 }
4038
4039 fn freeze_plugin_stdio_paths(config: &mut McpServerConfig, staged_root: &Path) -> Result<()> {
4040 if let Some(command) = config.command.as_mut()
4041 && (command.contains('/') || command.contains('\\'))
4042 {
4043 let frozen = resolve_plugin_mcp_cwd(staged_root, Some(Path::new(command)))?;
4044 *command = frozen.display().to_string();
4045 }
4046 let runtime_cwd = config.cwd.as_deref().unwrap_or(staged_root).to_path_buf();
4047 for argument in &mut config.args {
4048 if argument.starts_with('-') || Path::new(argument).is_absolute() {
4049 continue;
4050 }
4051 let candidate = normalize_path_components(&runtime_cwd.join(argument.as_str()));
4052 if candidate.exists() {
4053 let frozen = candidate
4054 .canonicalize()
4055 .context("failed to freeze reviewed plugin MCP argument path")?;
4056 if !frozen.starts_with(staged_root) {
4057 anyhow::bail!("reviewed plugin MCP argument path escaped its staged root");
4058 }
4059 *argument = frozen.display().to_string();
4060 }
4061 }
4062 Ok(())
4063 }
4064
4065 fn resolve_plugin_mcp_cwd(plugin_path: &Path, cwd: Option<&Path>) -> Result<PathBuf> {
4066 let cwd = match cwd {
4067 Some(cwd) if cwd.is_relative() => normalize_path_components(&plugin_path.join(cwd)),
4068 Some(cwd) => normalize_path_components(cwd),
4069 None => plugin_path.to_path_buf(),
4070 };
4071 let resolved = cwd
4072 .canonicalize()
4073 .unwrap_or_else(|_| normalize_path_components(&cwd));
4074 if !resolved.starts_with(plugin_path) {
4075 anyhow::bail!("reviewed plugin MCP path escaped its staged root");
4076 }
4077 Ok(resolved)
4078 }
4079
4080 fn workspace_allows_project_mcp_config(workspace: &Path) -> bool {
4081 crate::config::is_workspace_trusted(workspace)
4082 }
4083
4084 fn checked_workspace_mcp_config_path(workspace: &Path) -> Result<PathBuf> {
4085 Ok(checked_workspace_path(workspace)?
4086 .join(".codewhale")
4087 .join("mcp.json"))
4088 }
4089
4090 fn checked_workspace_path(workspace: &Path) -> Result<PathBuf> {
4091 if workspace.as_os_str().is_empty() {
4092 anyhow::bail!("workspace path cannot be empty");
4093 }
4094 if workspace
4095 .components()
4096 .any(|component| matches!(component, Component::ParentDir))
4097 {
4098 anyhow::bail!("workspace path cannot contain '..' components");
4099 }
4100 let absolute = if workspace.is_absolute() {
4101 workspace.to_path_buf()
4102 } else {
4103 std::env::current_dir()
4104 .context("failed to resolve current directory for workspace")?
4105 .join(workspace)
4106 };
4107 match absolute.canonicalize() {
4108 Ok(path) => Ok(path),
4109 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4110 Ok(normalize_path_components(&absolute))
4111 }
4112 Err(err) => {
4113 Err(err).with_context(|| format!("failed to resolve workspace {}", workspace.display()))
4114 }
4115 }
4116 }
4117
4118 fn normalize_workspace_path(workspace: &Path) -> PathBuf {
4119 if let Ok(canonical) = workspace.canonicalize() {
4120 return canonical;
4121 }
4122 let absolute = if workspace.is_absolute() {
4123 workspace.to_path_buf()
4124 } else {
4125 std::env::current_dir()
4126 .unwrap_or_else(|_| PathBuf::from("."))
4127 .join(workspace)
4128 };
4129 normalize_path_components(&absolute)
4130 }
4131
4132 fn resolve_project_mcp_cwd(workspace: &Path, cwd: Option<&Path>) -> Result<PathBuf> {
4133 let cwd = match cwd {
4134 Some(cwd) if cwd.is_relative() => normalize_path_components(&workspace.join(cwd)),
4135 Some(cwd) => normalize_path_components(cwd),
4136 None => workspace.to_path_buf(),
4137 };
4138 let resolved = cwd
4139 .canonicalize()
4140 .unwrap_or_else(|_| normalize_path_components(&cwd));
4141 if !resolved.starts_with(workspace) {
4142 anyhow::bail!(
4143 "Project MCP server cwd must stay within workspace: {}",
4144 resolved.display()
4145 );
4146 }
4147 Ok(resolved)
4148 }
4149
4150 fn normalize_path_components(path: &Path) -> PathBuf {
4151 let mut normalized = PathBuf::new();
4152 for component in path.components() {
4153 match component {
4154 Component::Prefix(_) | Component::RootDir => {
4155 normalized.push(component.as_os_str());
4156 }
4157 Component::CurDir => {}
4158 Component::ParentDir => {
4159 normalized.pop();
4160 }
4161 Component::Normal(part) => normalized.push(part),
4162 }
4163 }
4164 if normalized.as_os_str().is_empty() {
4165 PathBuf::from(".")
4166 } else {
4167 normalized
4168 }
4169 }
4170
4171 fn paths_refer_to_same_config(left: &Path, right: &Path) -> bool {
4172 match (left.canonicalize(), right.canonicalize()) {
4173 (Ok(left), Ok(right)) => left == right,
4174 _ => normalize_workspace_path(left) == normalize_workspace_path(right),
4175 }
4176 }
4177
4178 /// 64-bit content hash of an [`McpConfig`]. Used by [`McpPool`] to decide
4179 /// whether a freshly-read config differs from the one currently driving the
4180 /// live connections. Hashing the JSON serialization avoids forcing every
4181 /// nested config type to derive `Hash` (the timeouts struct, network policy
4182 /// stubs, etc.). The hash is stable across runs of the same Rust toolchain
4183 /// for byte-identical input.
4184 fn hash_mcp_config(config: &McpConfig) -> u64 {
4185 use std::hash::{Hash, Hasher};
4186 let bytes = serde_json::to_vec(config).unwrap_or_default();
4187 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4188 bytes.hash(&mut hasher);
4189 hasher.finish()
4190 }
4191
4192 /// Best-effort fetch of the MCP config file's last-modified time. Returns
4193 /// `None` when the file is missing, when stat fails, when the platform
4194 /// doesn't expose mtime, or when the path fails the same allow-list check
4195 /// that `load_config` / `save_config` apply. The lazy-reload check in
4196 /// `McpPool::get_or_connect` treats `None` as "skip the check this turn",
4197 /// so a rejected path simply degrades to "no auto-reload" rather than an
4198 /// error path. Callers already validate via `validate_mcp_config_path` at
4199 /// construction time; the redundant validation here keeps this helper
4200 /// safe-by-construction for any future caller and ties the validation to
4201 /// the call site rather than relying on cross-function reasoning.
4202 fn mcp_config_mtime(path: &Path) -> Option<std::time::SystemTime> {
4203 validate_mcp_config_path(path).ok()?;
4204 fs::metadata(path).ok()?.modified().ok()
4205 }
4206
4207 pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<()> {
4208 validate_mcp_config_path(path)?;
4209 if let Some(parent) = path.parent() {
4210 fs::create_dir_all(parent).with_context(|| {
4211 format!("Failed to create MCP config directory {}", parent.display())
4212 })?;
4213 }
4214 let rendered = serde_json::to_string_pretty(cfg).context("Failed to serialize MCP config")?;
4215 write_atomic(path, rendered.as_bytes())
4216 .with_context(|| format!("Failed to write MCP config {}", path.display()))?;
4217 Ok(())
4218 }
4219
4220 fn mcp_template_json() -> Result<String> {
4221 let mut cfg = McpConfig::default();
4222 cfg.servers.insert(
4223 "example".to_string(),
4224 McpServerConfig {
4225 command: Some("node".to_string()),
4226 args: vec!["./path/to/your-mcp-server.js".to_string()],
4227 env: HashMap::new(),
4228 cwd: None,
4229 url: None,
4230 transport: None,
4231 connect_timeout: None,
4232 execute_timeout: None,
4233 read_timeout: None,
4234 disabled: true,
4235 enabled: true,
4236 required: false,
4237 enabled_tools: Vec::new(),
4238 disabled_tools: Vec::new(),
4239 headers: HashMap::new(),
4240 env_headers: HashMap::new(),
4241 bearer_token_env_var: None,
4242 scopes: Vec::new(),
4243 oauth: None,
4244 oauth_resource: None,
4245 reviewed_plugin: None,
4246 },
4247 );
4248 serde_json::to_string_pretty(&cfg).context("Failed to render MCP template JSON")
4249 }
4250
4251 pub fn init_config(path: &Path, force: bool) -> Result<McpWriteStatus> {
4252 validate_mcp_config_path(path)?;
4253 if path.exists() && !force {
4254 return Ok(McpWriteStatus::SkippedExists);
4255 }
4256 let status = if path.exists() {
4257 McpWriteStatus::Overwritten
4258 } else {
4259 McpWriteStatus::Created
4260 };
4261 if let Some(parent) = path.parent() {
4262 fs::create_dir_all(parent).with_context(|| {
4263 format!("Failed to create MCP config directory {}", parent.display())
4264 })?;
4265 }
4266 let template = mcp_template_json()?;
4267 write_atomic(path, template.as_bytes())
4268 .with_context(|| format!("Failed to write MCP config {}", path.display()))?;
4269 Ok(status)
4270 }
4271
4272 pub fn add_server_config(
4273 path: &Path,
4274 name: String,
4275 command: Option<String>,
4276 url: Option<String>,
4277 args: Vec<String>,
4278 transport: Option<String>,
4279 ) -> Result<()> {
4280 if command.is_none() && url.is_none() {
4281 anyhow::bail!("Provide either a command or URL for MCP server '{name}'.");
4282 }
4283 validate_mcp_transport(transport.as_deref())?;
4284 let mut cfg = load_config(path)?;
4285 cfg.servers.insert(
4286 name,
4287 McpServerConfig {
4288 command,
4289 args,
4290 env: HashMap::new(),
4291 cwd: None,
4292 url,
4293 transport,
4294 connect_timeout: None,
4295 execute_timeout: None,
4296 read_timeout: None,
4297 disabled: false,
4298 enabled: true,
4299 required: false,
4300 enabled_tools: Vec::new(),
4301 disabled_tools: Vec::new(),
4302 headers: HashMap::new(),
4303 env_headers: HashMap::new(),
4304 bearer_token_env_var: None,
4305 scopes: Vec::new(),
4306 oauth: None,
4307 oauth_resource: None,
4308 reviewed_plugin: None,
4309 },
4310 );
4311 save_config(path, &cfg)
4312 }
4313
4314 pub fn remove_server_config(path: &Path, name: &str) -> Result<()> {
4315 let mut cfg = load_config(path)?;
4316 if cfg.servers.remove(name).is_none() {
4317 anyhow::bail!("MCP server '{name}' not found");
4318 }
4319 save_config(path, &cfg)
4320 }
4321
4322 pub fn set_server_enabled(path: &Path, name: &str, enabled: bool) -> Result<()> {
4323 let mut cfg = load_config(path)?;
4324 let server = cfg
4325 .servers
4326 .get_mut(name)
4327 .ok_or_else(|| anyhow::anyhow!("MCP server '{name}' not found"))?;
4328 server.enabled = enabled;
4329 server.disabled = !enabled;
4330 save_config(path, &cfg)
4331 }
4332
4333 #[cfg(test)]
4334 pub fn manager_snapshot_from_config(
4335 path: &Path,
4336 reload_required: bool,
4337 ) -> Result<McpManagerSnapshot> {
4338 let cfg = load_config(path)?;
4339 Ok(snapshot_from_config(
4340 path,
4341 path.exists(),
4342 reload_required,
4343 &cfg,
4344 None,
4345 ))
4346 }
4347
4348 #[cfg(test)]
4349 pub fn manager_snapshot_from_config_with_workspace(
4350 path: &Path,
4351 workspace: &Path,
4352 reload_required: bool,
4353 ) -> Result<McpManagerSnapshot> {
4354 let plugins = crate::plugins::PluginRegistry::empty(workspace);
4355 manager_snapshot_from_config_with_workspace_and_plugins(
4356 path,
4357 workspace,
4358 reload_required,
4359 &plugins,
4360 )
4361 }
4362
4363 pub fn manager_snapshot_from_config_with_workspace_and_plugins(
4364 path: &Path,
4365 workspace: &Path,
4366 reload_required: bool,
4367 plugins: &crate::plugins::PluginRegistry,
4368 ) -> Result<McpManagerSnapshot> {
4369 let cfg = load_config_with_workspace_and_plugins(path, workspace, plugins)?;
4370 Ok(snapshot_from_config(
4371 path,
4372 path.exists(),
4373 reload_required,
4374 &cfg,
4375 None,
4376 ))
4377 }
4378
4379 #[cfg(test)]
4380 pub async fn discover_manager_snapshot(
4381 path: &Path,
4382 network_policy: Option<NetworkPolicyDecider>,
4383 reload_required: bool,
4384 ) -> Result<McpManagerSnapshot> {
4385 let cfg = load_config(path)?;
4386 let mut pool = McpPool::new(cfg.clone());
4387 if let Some(policy) = network_policy {
4388 pool = pool.with_network_policy(policy);
4389 }
4390 let errors = pool
4391 .connect_all()
4392 .await
4393 .into_iter()
4394 .map(|(name, err)| (name, format_mcp_error_for_display(&err)))
4395 .collect::<HashMap<_, _>>();
4396 Ok(snapshot_from_config(
4397 path,
4398 path.exists(),
4399 reload_required,
4400 &cfg,
4401 Some((&pool, &errors)),
4402 ))
4403 }
4404
4405 pub async fn discover_manager_snapshot_with_workspace_and_plugins(
4406 path: &Path,
4407 workspace: &Path,
4408 network_policy: Option<NetworkPolicyDecider>,
4409 reload_required: bool,
4410 plugins: Arc<crate::plugins::PluginRegistry>,
4411 ) -> Result<McpManagerSnapshot> {
4412 let cfg = load_config_with_workspace_and_plugins(path, workspace, plugins.as_ref())?;
4413 let mut pool = McpPool::new(cfg.clone());
4414 pool.workspace = Some(checked_workspace_path(workspace)?);
4415 pool.plugin_registry = Some(plugins);
4416 if let Some(policy) = network_policy {
4417 pool = pool.with_network_policy(policy);
4418 }
4419 let errors = pool
4420 .connect_all()
4421 .await
4422 .into_iter()
4423 .map(|(name, err)| (name, format_mcp_error_for_display(&err)))
4424 .collect::<HashMap<_, _>>();
4425 Ok(snapshot_from_config(
4426 path,
4427 path.exists(),
4428 reload_required,
4429 &cfg,
4430 Some((&pool, &errors)),
4431 ))
4432 }
4433
4434 pub(crate) fn format_mcp_error_for_display(error: &anyhow::Error) -> String {
4435 codewhale_config::persistence::redact_secrets(&format!("{error:#}"))
4436 }
4437
4438 impl McpPool {
4439 /// Snapshot the live pool rather than starting a second discovery pool.
4440 /// This keeps the manager, hotbar, and next model turn aligned on one
4441 /// exact config/catalog generation.
4442 pub(crate) fn manager_snapshot(
4443 &self,
4444 path: &Path,
4445 reload_required: bool,
4446 errors: &HashMap<String, String>,
4447 ) -> McpManagerSnapshot {
4448 snapshot_from_config(
4449 path,
4450 path.exists(),
4451 reload_required,
4452 &self.config,
4453 Some((self, errors)),
4454 )
4455 }
4456 }
4457
4458 fn snapshot_from_config(
4459 path: &Path,
4460 config_exists: bool,
4461 reload_required: bool,
4462 cfg: &McpConfig,
4463 discovery: Option<(&McpPool, &HashMap<String, String>)>,
4464 ) -> McpManagerSnapshot {
4465 let mut servers = cfg
4466 .servers
4467 .iter()
4468 .map(|(name, server)| {
4469 let transport = if server.url.is_some() {
4470 if is_legacy_sse_transport(server) {
4471 "sse"
4472 } else {
4473 "http/sse"
4474 }
4475 } else {
4476 "stdio"
4477 };
4478 let command_or_url = server.url.clone().unwrap_or_else(|| {
4479 let mut command = server
4480 .command
4481 .clone()
4482 .unwrap_or_else(|| "(missing)".to_string());
4483 if !server.args.is_empty() {
4484 command.push(' ');
4485 command.push_str(&server.args.join(" "));
4486 }
4487 command
4488 });
4489 let mut snapshot = McpServerSnapshot {
4490 name: name.clone(),
4491 enabled: server.is_enabled(),
4492 required: server.required,
4493 transport: transport.to_string(),
4494 command_or_url,
4495 connect_timeout: server.effective_connect_timeout(&cfg.timeouts),
4496 execute_timeout: server.effective_execute_timeout(&cfg.timeouts),
4497 read_timeout: server.effective_read_timeout(&cfg.timeouts),
4498 connected: false,
4499 error: if server.is_enabled() {
4500 None
4501 } else {
4502 Some("disabled".to_string())
4503 },
4504 tools: Vec::new(),
4505 resources: Vec::new(),
4506 prompts: Vec::new(),
4507 };
4508
4509 if let Some((pool, errors)) = discovery {
4510 if let Some(error) = errors.get(name) {
4511 snapshot.error = Some(error.clone());
4512 }
4513 if let Some(conn) = pool.connections.get(name) {
4514 snapshot.connected = conn.is_ready();
4515 if snapshot.connected {
4516 // A count of connected servers and nothing else. The
4517 // name, the command or URL, and the error string are
4518 // user-chosen and routinely name internal infra.
4519 codewhale_telemetry::session_counters()
4520 .bump(codewhale_telemetry::Counter::McpServerConnected);
4521 }
4522 snapshot.tools = conn
4523 .tools()
4524 .iter()
4525 .filter(|tool| conn.config().is_tool_enabled(&tool.name))
4526 .map(|tool| McpDiscoveredItem {
4527 name: tool.name.clone(),
4528 model_name: format!("mcp_{}_{}", name, tool.name),
4529 description: tool.description.clone(),
4530 })
4531 .collect();
4532 snapshot.resources =
4533 conn.resources()
4534 .iter()
4535 .map(|resource| McpDiscoveredItem {
4536 name: resource.name.clone(),
4537 model_name: format!(
4538 "mcp_{}_{}",
4539 name,
4540 resource.name.replace(' ', "_").to_lowercase()
4541 ),
4542 description: resource.description.clone(),
4543 })
4544 .chain(conn.resource_templates().iter().map(|template| {
4545 McpDiscoveredItem {
4546 name: template.name.clone(),
4547 model_name: format!(
4548 "mcp_{}_{}",
4549 name,
4550 template.name.replace(' ', "_").to_lowercase()
4551 ),
4552 description: template.description.clone(),
4553 }
4554 }))
4555 .collect();
4556 snapshot.prompts = conn
4557 .prompts()
4558 .iter()
4559 .map(|prompt| McpDiscoveredItem {
4560 name: prompt.name.clone(),
4561 model_name: format!("mcp_{}_{}", name, prompt.name),
4562 description: prompt.description.clone(),
4563 })
4564 .collect();
4565 }
4566 }
4567
4568 snapshot
4569 })
4570 .collect::<Vec<_>>();
4571 servers.sort_by(|a, b| a.name.cmp(&b.name));
4572 McpManagerSnapshot {
4573 config_path: path.to_path_buf(),
4574 config_exists,
4575 reload_required,
4576 servers,
4577 }
4578 }
4579
4580 // === Unit Tests ===
4581
4582 #[cfg(test)]
4583 mod tests;
4584
4584 lines RUST