Skip to main content

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;