zeph_mcp/manager/mod.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
9use tokio_util::sync::CancellationToken;
10
11use dashmap::DashMap;
12use tokio::sync::RwLock;
13use tokio::sync::{mpsc, watch};
14
15type StatusTx = mpsc::UnboundedSender<String>;
16/// Per-server trust config: (`trust_level`, `tool_allowlist`, `expected_tools`,
17/// `allow_untrusted_without_allowlist`).
18type ServerTrust = Arc<
19 tokio::sync::RwLock<HashMap<String, (McpTrustLevel, Option<Vec<String>>, Vec<String>, bool)>>,
20>;
21
22use rmcp::transport::auth::CredentialStore;
23
24use crate::client::{McpClient, ToolRefreshEvent};
25use crate::elicitation::ElicitationEvent;
26use crate::embedding_guard::EmbeddingAnomalyGuard;
27use crate::policy::PolicyEnforcer;
28use crate::prober::DefaultMcpProber;
29use crate::tool::{McpTool, ToolSecurityMeta};
30use crate::trust_score::TrustScoreStore;
31
32fn default_elicitation_timeout() -> u64 {
33 120
34}
35
36/// Trust level for an MCP server connection.
37///
38/// Controls SSRF validation and tool filtering on connect and refresh.
39pub(crate) use zeph_config::McpTrustLevel;
40
41/// Maximum number of injection penalties applied per tool registration batch.
42///
43/// Caps the per-registration trust penalty at `MAX * INJECTION_PENALTY` to prevent
44/// a single registration with many flagged descriptions (e.g. from false positives)
45/// from permanently destroying server trust.
46const MAX_INJECTION_PENALTIES_PER_REGISTRATION: usize = 3;
47
48/// Transport type for MCP server connections.
49///
50/// `Serialize` is hand-written and redacts `Stdio.env` / `Http.headers` values to
51/// `"[REDACTED]"` (keys are kept for diagnostics), mirroring the `Debug` impl below;
52/// `Deserialize` is derived and reads real values untouched (needed for ACP `mcp/add`).
53#[non_exhaustive]
54#[derive(Clone, serde::Deserialize)]
55pub enum McpTransport {
56 /// Stdio: spawn child process with command + args.
57 Stdio {
58 command: String,
59 args: Vec<String>,
60 env: HashMap<String, String>,
61 },
62 /// Streamable HTTP with optional static headers (already resolved, no vault refs).
63 Http {
64 url: String,
65 /// Static headers injected into every request (e.g. `Authorization: Bearer <token>`).
66 #[serde(default)]
67 headers: HashMap<String, String>,
68 },
69 /// OAuth 2.1 authenticated HTTP transport.
70 OAuth {
71 url: String,
72 scopes: Vec<String>,
73 callback_port: u16,
74 client_name: String,
75 },
76}
77
78impl std::fmt::Debug for McpTransport {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::Stdio { command, args, env } => {
82 // Env values commonly carry secrets (e.g. `GITHUB_PERSONAL_ACCESS_TOKEN`) —
83 // keep var names for diagnostics, redact values.
84 let redacted: HashMap<&str, &str> =
85 env.keys().map(|k| (k.as_str(), "[REDACTED]")).collect();
86 f.debug_struct("Stdio")
87 .field("command", command)
88 .field("args", args)
89 .field("env", &redacted)
90 .finish()
91 }
92 Self::Http { url, headers } => {
93 // Header values may carry vault-resolved secrets (e.g. `Authorization`)
94 // resolved by `build_transport` — keep keys for diagnostics, redact values.
95 let redacted: HashMap<&str, &str> =
96 headers.keys().map(|k| (k.as_str(), "[REDACTED]")).collect();
97 f.debug_struct("Http")
98 .field("url", url)
99 .field("headers", &redacted)
100 .finish()
101 }
102 Self::OAuth {
103 url,
104 scopes,
105 callback_port,
106 client_name,
107 } => f
108 .debug_struct("OAuth")
109 .field("url", url)
110 .field("scopes", scopes)
111 .field("callback_port", callback_port)
112 .field("client_name", client_name)
113 .finish(),
114 }
115 }
116}
117
118impl serde::Serialize for McpTransport {
119 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
120 use serde::ser::SerializeStructVariant;
121 match self {
122 Self::Stdio { command, args, env } => {
123 let redacted: HashMap<&str, &str> =
124 env.keys().map(|k| (k.as_str(), "[REDACTED]")).collect();
125 let mut v = serializer.serialize_struct_variant("McpTransport", 0, "Stdio", 3)?;
126 v.serialize_field("command", command)?;
127 v.serialize_field("args", args)?;
128 v.serialize_field("env", &redacted)?;
129 v.end()
130 }
131 Self::Http { url, headers } => {
132 let redacted: HashMap<&str, &str> =
133 headers.keys().map(|k| (k.as_str(), "[REDACTED]")).collect();
134 let mut v = serializer.serialize_struct_variant("McpTransport", 1, "Http", 2)?;
135 v.serialize_field("url", url)?;
136 v.serialize_field("headers", &redacted)?;
137 v.end()
138 }
139 Self::OAuth {
140 url,
141 scopes,
142 callback_port,
143 client_name,
144 } => {
145 let mut v = serializer.serialize_struct_variant("McpTransport", 2, "OAuth", 4)?;
146 v.serialize_field("url", url)?;
147 v.serialize_field("scopes", scopes)?;
148 v.serialize_field("callback_port", callback_port)?;
149 v.serialize_field("client_name", client_name)?;
150 v.end()
151 }
152 }
153 }
154}
155
156/// Connection parameters for a single MCP server consumed by [`McpManager`].
157///
158/// Deserialized from the `[[mcp.servers]]` TOML config table or constructed
159/// programmatically for tests. All fields except `id` and `transport` have
160/// reasonable defaults via `#[serde(default)]`.
161///
162/// # Trust semantics
163///
164/// The combination of `trust_level`, `tool_allowlist`, `expected_tools`, and
165/// `allow_untrusted_without_allowlist` controls which tools are exposed to the agent:
166///
167/// - `Trusted` — all tools are exposed; SSRF and data-flow checks are relaxed.
168/// - `Untrusted` + no allowlist — fails closed: zero tools exposed, unless
169/// `allow_untrusted_without_allowlist` is `true` (opt-in escape hatch that keeps the
170/// full untrusted pipeline — SSRF, sanitization, injection detection, attestation —
171/// while exposing all tools).
172/// - `Untrusted` + allowlist — only listed tools are exposed.
173/// - `Sandboxed` + allowlist — only listed tools; empty allowlist = no tools.
174/// - `Sandboxed` + no allowlist — no tools exposed (fail closed unconditionally;
175/// `allow_untrusted_without_allowlist` has no effect on `Sandboxed`).
176// `roots: Vec<rmcp::model::Root>` names a type deprecated by SEP-2577 (still functional —
177// see `crate::roots`); the derive(Serialize, Deserialize) expansion below also references
178// it, which a field-level `#[allow(deprecated)]` does not silence, hence the struct-level
179// attribute.
180#[allow(deprecated)]
181#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
182#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
183pub struct ServerEntry {
184 pub id: String,
185 pub transport: McpTransport,
186 pub timeout: Duration,
187 /// Trust level for this server. Controls SSRF validation and tool filtering.
188 /// `Trusted` skips SSRF checks (for operator-controlled static config).
189 #[serde(default)]
190 pub trust_level: McpTrustLevel,
191 /// Tool allowlist. `None` means no override (inherit from config or deny by default).
192 /// `Some(vec![])` is an explicit empty list. See `McpTrustLevel` for per-level semantics.
193 #[serde(default)]
194 pub tool_allowlist: Option<Vec<String>>,
195 /// Explicit opt-in to expose all tools for an `Untrusted` server with no
196 /// `tool_allowlist` declared. Default: `false` (secure by default — fails closed).
197 /// Mirrors [`McpServerConfig::allow_untrusted_without_allowlist`](zeph_config::McpServerConfig::allow_untrusted_without_allowlist).
198 #[serde(default)]
199 pub allow_untrusted_without_allowlist: bool,
200 /// Expected tool names for attestation. When non-empty, tools outside this
201 /// list are filtered (Untrusted/Sandboxed) or warned (Trusted).
202 #[serde(default)]
203 pub expected_tools: Vec<String>,
204 /// Filesystem roots to advertise to the server via `roots/list`.
205 ///
206 /// `rmcp::model::Root` is deprecated by SEP-2577 but still functional — see
207 /// [`crate::roots`] for the construction-helper boundary that isolates this.
208 #[serde(default)]
209 pub roots: Vec<rmcp::model::Root>,
210 /// Per-tool security metadata overrides. Keys are tool names.
211 /// When absent for a tool, metadata is inferred from the tool name via heuristics.
212 #[serde(default)]
213 pub tool_metadata: HashMap<String, ToolSecurityMeta>,
214 /// Whether this server is allowed to send elicitation requests.
215 /// Overrides the global `elicitation_enabled` config.
216 /// Sandboxed servers always have elicitation disabled regardless of this flag.
217 #[serde(default)]
218 pub elicitation_enabled: bool,
219 /// Timeout in seconds for the user to respond to an elicitation request.
220 #[serde(default = "default_elicitation_timeout")]
221 pub elicitation_timeout_secs: u64,
222 /// When `true`, spawn this Stdio server with an isolated environment: only the minimal
223 /// base env vars (`PATH`, `HOME`, etc.) plus this server's declared `env` map are passed.
224 ///
225 /// Default: `false` (backward compatible).
226 #[serde(default)]
227 pub env_isolation: bool,
228 /// Opt-in: decode and attach images this server returns as native `MessagePart::Image`
229 /// siblings for vision-capable providers (spec-072). Mirrors
230 /// [`McpServerConfig::media_passthrough`](zeph_config::McpServerConfig::media_passthrough).
231 /// Always hard-blocked when `trust_level == McpTrustLevel::Sandboxed`, regardless of
232 /// this flag. Default: `false`.
233 #[serde(default)]
234 pub media_passthrough: bool,
235}
236
237/// Configurable byte caps applied during tool ingestion and server-instructions storage.
238#[derive(Debug, Clone, Copy)]
239struct IngestLimits {
240 description_bytes: usize,
241 instructions_bytes: usize,
242}
243
244/// Owned output produced by a single [`McpManager::handle_connect_result`] call.
245///
246/// Accumulates the data that must be inserted into shared maps after all async work
247/// completes, so write guards are never held across `.await` points.
248struct ConnectOutput {
249 /// `Some((server_id, client))` on success, `None` on failure.
250 client_entry: Option<(String, McpClient)>,
251 /// `Some((server_id, tools))` on success, `None` on failure.
252 tools_entry: Option<(String, Vec<McpTool>)>,
253 /// Flattened tool list to extend `all_tools` (empty on failure).
254 tools: Vec<McpTool>,
255 /// Per-server outcome (both success and failure).
256 outcome: ServerConnectOutcome,
257 /// `Some((server_id, truncated_instructions))` when the server sent instructions.
258 instructions: Option<(String, String)>,
259 /// `Some((server_id, fingerprints))` when attestation computed fingerprints for this
260 /// connection (i.e. `expected_tools` is configured for the server). `None` on failure
261 /// or when attestation is unconfigured.
262 fingerprints: Option<(String, HashMap<String, crate::attestation::ToolFingerprint>)>,
263}
264
265/// Outcome of a single server connection attempt from [`McpManager::connect_all`].
266///
267/// One `ServerConnectOutcome` is returned per configured server. Inspect `connected`
268/// to distinguish success from failure; `error` is empty when `connected` is `true`.
269#[derive(Debug, Clone)]
270pub struct ServerConnectOutcome {
271 /// Server ID from [`ServerEntry::id`].
272 pub id: String,
273 /// `true` if the connection and tool list retrieval succeeded.
274 pub connected: bool,
275 /// Number of tools registered after sanitization and trust filtering.
276 pub tool_count: usize,
277 /// Human-readable failure reason. Empty when `connected` is `true`.
278 pub error: String,
279 /// Number of `input_schema`s dropped for exceeding `MAX_SCHEMA_DEPTH` (`0` on failure).
280 /// See [`crate::sanitize::SanitizeResult::input_schemas_dropped`].
281 pub input_schemas_dropped: usize,
282 /// Number of `output_schema`s dropped for injection or exceeding `MAX_SCHEMA_DEPTH`
283 /// (`0` on failure). See [`crate::sanitize::SanitizeResult::output_schemas_dropped`].
284 pub output_schemas_dropped: usize,
285}
286
287/// Multi-server MCP lifecycle manager.
288///
289/// `McpManager` owns connections to all configured MCP servers. It drives the full
290/// security pipeline (command allowlist, SSRF, attestation, sanitization, data-flow
291/// policy, trust scoring, embedding anomaly detection) and exposes a single
292/// `call_tool()` entry point for tool execution.
293///
294/// # Lifecycle
295///
296/// 1. Construct with [`McpManager::new`] (or [`McpManager::with_elicitation_capacity`]).
297/// 2. Chain builder methods (`with_prober`, `with_trust_store`, `with_lock_tool_list`, …).
298/// 3. Call [`McpManager::connect_all`] to establish connections; receives initial tool list.
299/// 4. Call [`McpManager::spawn_refresh_task`] to start the background refresh handler.
300/// 5. Use [`McpManager::call_tool`] to invoke tools during agent turns.
301/// 6. Call [`McpManager::shutdown_all_shared`] on exit.
302///
303/// # Sharing across tasks
304///
305/// `McpManager` is cheaply cloneable via `Arc` wrapping of its internal maps, making it
306/// safe to share across async tasks. Most methods take `&self`.
307pub struct McpManager {
308 configs: Vec<ServerEntry>,
309 allowed_commands: Vec<String>,
310 clients: Arc<RwLock<HashMap<String, McpClient>>>,
311 connected_server_ids: SyncRwLock<HashSet<String>>,
312 enforcer: Arc<PolicyEnforcer>,
313 suppress_stderr: bool,
314 /// Per-server tool lists; updated by the refresh task.
315 server_tools: Arc<RwLock<HashMap<String, Vec<McpTool>>>>,
316 /// Sender half of the refresh event channel; cloned into each `ToolListChangedHandler`.
317 /// Wrapped in Mutex<Option<...>> so `shutdown_all_shared()` can drop it while holding `&self`.
318 /// When this sender and all handler senders are dropped, the refresh task terminates.
319 /// Bounded at 16: on `TrySendError::Full` the notification is dropped — latest-wins semantics.
320 refresh_tx: SyncMutex<Option<mpsc::Sender<ToolRefreshEvent>>>,
321 /// Receiver half; taken once by `spawn_refresh_task()`.
322 refresh_rx: SyncMutex<Option<mpsc::Receiver<ToolRefreshEvent>>>,
323 /// Broadcasts the full flattened tool list after any server refresh.
324 tools_watch_tx: watch::Sender<Vec<McpTool>>,
325 /// Shared rate-limit state across all `ToolListChangedHandler` instances.
326 last_refresh: Arc<DashMap<String, Instant>>,
327 /// Per-server OAuth credential stores. Keyed by server ID.
328 /// Set via `with_oauth_credential_store` before `connect_all()`.
329 oauth_credentials: HashMap<String, Arc<dyn CredentialStore>>,
330 /// Optional status sender for OAuth authorization messages.
331 /// When set, the authorization URL is sent as a status message instead of
332 /// (or in addition to) printing to stderr — required for TUI and Telegram modes.
333 status_tx: Option<StatusTx>,
334 /// Per-server trust configuration for tool filtering.
335 /// Behind `Arc<RwLock>` because refresh tasks read it from spawned closures
336 /// and `add_server()` writes to it.
337 server_trust: ServerTrust,
338 /// Tool fingerprints from each server's most recent successful connection, used to
339 /// detect schema drift (the "MCP rug-pull" mitigation) on the next reconnect or
340 /// `tools/list_changed` refresh. Keyed by server ID. Only populated for servers with
341 /// `expected_tools` configured (attestation must be configured for drift detection
342 /// to run — see [`crate::attestation::attest_tools`]).
343 server_fingerprints:
344 Arc<RwLock<HashMap<String, HashMap<String, crate::attestation::ToolFingerprint>>>>,
345 /// Optional pre-connect prober. When set, called on every new server connection.
346 prober: Option<DefaultMcpProber>,
347 /// Optional persistent trust score store. When set, probe results are persisted.
348 trust_store: Option<Arc<TrustScoreStore>>,
349 /// Optional embedding anomaly guard. When set, called after every successful tool call.
350 embedding_guard: Option<EmbeddingAnomalyGuard>,
351 /// Per-server tool metadata overrides. Immutable after construction.
352 server_tool_metadata: Arc<HashMap<String, HashMap<String, ToolSecurityMeta>>>,
353 /// Configurable cap for tool description length (bytes). Default: 2048.
354 max_description_bytes: usize,
355 /// Configurable cap for server instructions length (bytes). Default: 2048.
356 max_instructions_bytes: usize,
357 /// Server instructions collected after handshake, keyed by server ID.
358 server_instructions: Arc<RwLock<HashMap<String, String>>>,
359 /// Sender half of the bounded elicitation event channel; cloned into each
360 /// `ToolListChangedHandler` that has elicitation enabled.
361 elicitation_tx: SyncMutex<Option<mpsc::Sender<ElicitationEvent>>>,
362 /// Receiver half; taken once by `take_elicitation_rx()` and wired into the agent loop.
363 elicitation_rx: SyncMutex<Option<mpsc::Receiver<ElicitationEvent>>>,
364 /// Per-server elicitation enabled flags (populated from `ServerEntry`).
365 server_elicitation: HashMap<String, bool>,
366 /// Per-server elicitation timeout in seconds.
367 server_elicitation_timeout: HashMap<String, u64>,
368 /// Serializes all add/remove operations to prevent the `commit_added_server` + `remove_server` race.
369 ///
370 /// Without this lock a concurrent `remove_server` could remove a client from `clients` after
371 /// `commit_added_server` releases the `clients` guard but before it writes to `server_trust`
372 /// and `server_tools`, leaving orphaned trust/tools entries that persist until restart.
373 add_remove_lock: tokio::sync::Mutex<()>,
374 /// Cancellation token broadcast to all in-flight startup retry tasks.
375 ///
376 /// Cancelled in `shutdown_all_shared` before any other shutdown work so that retry
377 /// sleeps are interrupted immediately rather than contributing tail latency.
378 shutdown_token: CancellationToken,
379 /// Maximum number of connection attempts per server at startup.
380 ///
381 /// `1` = no retry, `3` = two retries. Validated at config-parse time: `1..=10`.
382 max_connect_attempts: u8,
383 /// Base delay in milliseconds for exponential backoff between startup retry attempts.
384 ///
385 /// The actual delay is `min(startup_retry_backoff_ms * 2^(attempt-1), 8_000) ms`.
386 /// Default: 1 000 ms.
387 startup_retry_backoff_ms: u64,
388 /// Per-call timeout applied to each `tools/call` request after connection is established.
389 ///
390 /// When `Some`, overrides the per-server `ServerEntry.timeout` for tool calls only.
391 /// When `None`, the per-server `ServerEntry.timeout` is used for all operations.
392 /// Default: `None` (uses per-server timeout).
393 tool_timeout_secs: Option<u64>,
394 /// When `true`, `tools/list_changed` refresh events are rejected for servers whose
395 /// initial tool list has been committed (i.e. their ID is in `tool_list_locked`).
396 ///
397 /// This prevents a server from smuggling new tools mid-session after attestation.
398 lock_tool_list: bool,
399 /// Set of server IDs whose tool lists are locked. A server is added here atomically
400 /// before `connect_entry` is called so the lock is in place before the server can
401 /// send a `tools/list_changed` notification (MF-2: no TOCTOU window).
402 tool_list_locked: Arc<DashMap<String, ()>>,
403}
404
405impl std::fmt::Debug for McpManager {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 f.debug_struct("McpManager")
408 .field("server_count", &self.configs.len())
409 .finish_non_exhaustive()
410 }
411}
412
413/// Configuration bundle passed to [`ingest_tools`].
414///
415/// Consolidates all per-server policy parameters so call sites pass a single
416/// reference instead of eight positional arguments.
417struct IngestConfig<'a> {
418 /// Stable identifier of the MCP server being ingested.
419 server_id: &'a str,
420 /// Trust classification that governs allowlist and attestation enforcement.
421 trust_level: McpTrustLevel,
422 /// Explicit tool allow-list from operator config (`None` = not configured).
423 allowlist: Option<&'a [String]>,
424 /// Explicit opt-in to expose all tools for an `Untrusted` server with no `allowlist`.
425 /// See [`ServerEntry::allow_untrusted_without_allowlist`].
426 allow_untrusted_without_allowlist: bool,
427 /// Operator-declared set of expected tool names used for attestation.
428 expected_tools: &'a [String],
429 /// Channel for surfacing warnings to the user-facing status bar.
430 status_tx: Option<&'a StatusTx>,
431 /// Maximum byte length for tool descriptions; longer descriptions are truncated.
432 max_description_bytes: usize,
433 /// Per-tool security metadata overrides keyed by tool name.
434 tool_metadata: &'a HashMap<String, ToolSecurityMeta>,
435 /// Tool fingerprints from the previous connection for this server, used for
436 /// schema-drift detection on reconnect. `None` when this is the first connection
437 /// or no prior fingerprints are cached.
438 previous_fingerprints: Option<&'a HashMap<String, crate::attestation::ToolFingerprint>>,
439}
440
441mod builder;
442mod call;
443mod connect;
444mod ingest;
445mod retry;
446mod server;
447
448#[cfg(test)]
449mod tests;