Skip to main content

falsegreen_agent/
mcp.rs

1//! MCP client integration backed by the official `rmcp` Rust SDK.
2//!
3//! This module owns configured MCP connections, negotiated server metadata, tool
4//! discovery, deterministic namespacing, bounded calls, and graceful shutdown.
5//! It deliberately does not own the agent runtime or FalseGreen authority logic.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::env;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Mutex, MutexGuard, OnceLock};
14use std::time::Duration;
15
16use http::{HeaderName, HeaderValue};
17use rmcp::model::{
18    CallToolRequest, CallToolRequestParams, ClientCapabilities, ClientInfo, ClientRequest,
19    Implementation, ListToolsRequest, PaginatedRequestParams, ServerPeerInfo, ServerResult, Tool,
20};
21use rmcp::service::{PeerRequestOptions, RunningService};
22use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
23use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
24use rmcp::{RoleClient, ServiceExt};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value, json};
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29use tokio::runtime::Runtime;
30use url::Url;
31
32use crate::event::EventStore;
33use crate::genui::{
34    ActionCatalog, GenUiError, Surface, adapt_mcp_schema, mcp_schema_digest, mcp_tool_surface,
35    validate_mcp_submission,
36};
37use crate::inference::ToolCall;
38use crate::tools::ToolResult;
39
40const CONFIG_VERSION: u32 = 1;
41const MAX_CONFIG_BYTES: usize = 1024 * 1024;
42const MAX_SERVERS: usize = 32;
43const MAX_TOOLS_PER_SERVER: usize = 1024;
44const MAX_LIST_PAGES: usize = 64;
45const MAX_REQUEST_BYTES_HARD: usize = 1024 * 1024;
46const MAX_RESULT_BYTES_HARD: usize = 4 * 1024 * 1024;
47const MAX_SCHEMA_BYTES_HARD: usize = 4 * 1024 * 1024;
48const MAX_TIMEOUT_MS: u64 = 15 * 60 * 1000;
49const DEFAULT_STARTUP_TIMEOUT_MS: u64 = 15_000;
50const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 60_000;
51const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 3_000;
52const DEFAULT_MAX_REQUEST_BYTES: usize = 256 * 1024;
53const DEFAULT_MAX_RESULT_BYTES: usize = 256 * 1024;
54const DEFAULT_MAX_SCHEMA_BYTES: usize = 512 * 1024;
55const MAX_ERROR_BYTES: usize = 64 * 1024;
56
57static MCP_PUBLICATION_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
58
59pub(crate) type McpPublicationGuard = MutexGuard<'static, ()>;
60
61pub(crate) fn acquire_publication_guard() -> McpPublicationGuard {
62    MCP_PUBLICATION_LOCK
63        .get_or_init(|| Mutex::new(()))
64        .lock()
65        .expect("MCP publication lock is not poisoned")
66}
67
68#[derive(Debug, Error)]
69pub enum McpError {
70    #[error("MCP config I/O failed for {path}: {source}")]
71    ConfigIo {
72        path: String,
73        #[source]
74        source: std::io::Error,
75    },
76    #[error("MCP config exceeds the {MAX_CONFIG_BYTES} byte limit")]
77    ConfigTooLarge,
78    #[error("MCP config JSON is invalid: {0}")]
79    ConfigJson(#[from] serde_json::Error),
80    #[error("invalid MCP config: {0}")]
81    InvalidConfig(String),
82    #[error("failed to create the MCP async runtime: {0}")]
83    Runtime(#[source] std::io::Error),
84    #[error("MCP server {server:?} failed during {phase}: {message}")]
85    Server {
86        server: String,
87        phase: &'static str,
88        message: String,
89    },
90}
91
92#[derive(Debug, Clone, Deserialize)]
93#[serde(deny_unknown_fields)]
94struct McpConfig {
95    version: u32,
96    servers: BTreeMap<String, McpServerConfig>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100#[serde(tag = "transport", rename_all = "snake_case", deny_unknown_fields)]
101enum McpServerConfig {
102    Stdio {
103        command: String,
104        #[serde(default)]
105        args: Vec<String>,
106        #[serde(default)]
107        env: BTreeMap<String, String>,
108        #[serde(default)]
109        cwd: Option<PathBuf>,
110        #[serde(default = "default_startup_timeout_ms")]
111        startup_timeout_ms: u64,
112        #[serde(default = "default_request_timeout_ms")]
113        request_timeout_ms: u64,
114        #[serde(default = "default_shutdown_timeout_ms")]
115        shutdown_timeout_ms: u64,
116        #[serde(default = "default_max_request_bytes")]
117        max_request_bytes: usize,
118        #[serde(default = "default_max_result_bytes")]
119        max_result_bytes: usize,
120        #[serde(default = "default_max_schema_bytes")]
121        max_schema_bytes: usize,
122    },
123    StreamableHttp {
124        url: String,
125        #[serde(default)]
126        bearer_token_env: Option<String>,
127        #[serde(default)]
128        headers: BTreeMap<String, String>,
129        #[serde(default)]
130        header_env: BTreeMap<String, String>,
131        #[serde(default = "default_startup_timeout_ms")]
132        startup_timeout_ms: u64,
133        #[serde(default = "default_request_timeout_ms")]
134        request_timeout_ms: u64,
135        #[serde(default = "default_shutdown_timeout_ms")]
136        shutdown_timeout_ms: u64,
137        #[serde(default = "default_max_request_bytes")]
138        max_request_bytes: usize,
139        #[serde(default = "default_max_result_bytes")]
140        max_result_bytes: usize,
141        #[serde(default = "default_max_schema_bytes")]
142        max_schema_bytes: usize,
143    },
144}
145
146impl McpServerConfig {
147    const fn limits(&self) -> McpServerLimits {
148        match self {
149            Self::Stdio {
150                startup_timeout_ms,
151                request_timeout_ms,
152                shutdown_timeout_ms,
153                max_request_bytes,
154                max_result_bytes,
155                max_schema_bytes,
156                ..
157            }
158            | Self::StreamableHttp {
159                startup_timeout_ms,
160                request_timeout_ms,
161                shutdown_timeout_ms,
162                max_request_bytes,
163                max_result_bytes,
164                max_schema_bytes,
165                ..
166            } => McpServerLimits {
167                startup_timeout_ms: *startup_timeout_ms,
168                request_timeout_ms: *request_timeout_ms,
169                shutdown_timeout_ms: *shutdown_timeout_ms,
170                max_request_bytes: *max_request_bytes,
171                max_result_bytes: *max_result_bytes,
172                max_schema_bytes: *max_schema_bytes,
173            },
174        }
175    }
176}
177
178#[derive(Debug, Clone, Copy)]
179struct McpServerLimits {
180    startup_timeout_ms: u64,
181    request_timeout_ms: u64,
182    shutdown_timeout_ms: u64,
183    max_request_bytes: usize,
184    max_result_bytes: usize,
185    max_schema_bytes: usize,
186}
187
188const fn default_startup_timeout_ms() -> u64 {
189    DEFAULT_STARTUP_TIMEOUT_MS
190}
191
192const fn default_request_timeout_ms() -> u64 {
193    DEFAULT_REQUEST_TIMEOUT_MS
194}
195
196const fn default_shutdown_timeout_ms() -> u64 {
197    DEFAULT_SHUTDOWN_TIMEOUT_MS
198}
199
200const fn default_max_request_bytes() -> usize {
201    DEFAULT_MAX_REQUEST_BYTES
202}
203
204const fn default_max_result_bytes() -> usize {
205    DEFAULT_MAX_RESULT_BYTES
206}
207
208const fn default_max_schema_bytes() -> usize {
209    DEFAULT_MAX_SCHEMA_BYTES
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
213pub enum ToolPolicy {
214    ReadOnly,
215    Consequential,
216}
217
218/// A fresh, read-only projection of the MCP registry state used by the
219/// execution path. This is intentionally separate from any GenUI handle so a
220/// caller cannot claim that a cached handle is a live source query.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222pub struct LiveToolIdentity {
223    pub exposed_tool: String,
224    pub provider_id: String,
225    pub server_id: String,
226    pub remote_tool_name: String,
227    pub schema_digest: String,
228    pub policy_version: u64,
229    pub policy_digest: String,
230    pub requires_confirmation: bool,
231}
232
233impl ToolPolicy {
234    #[must_use]
235    pub const fn requires_confirmation(self) -> bool {
236        matches!(self, Self::Consequential)
237    }
238
239    #[must_use]
240    pub const fn as_str(self) -> &'static str {
241        match self {
242            Self::ReadOnly => "read_only",
243            Self::Consequential => "consequential",
244        }
245    }
246
247    #[must_use]
248    pub fn digest(self, version: u64) -> String {
249        sha256_hex(format!("falsegreen.tool-policy.v1|{}|{version}", self.as_str()).as_bytes())
250    }
251}
252
253#[derive(Debug, Clone)]
254struct ToolRoute {
255    connection_index: usize,
256    server: String,
257    remote_tool: String,
258    exposed_tool: String,
259    provider_id: String,
260    policy: ToolPolicy,
261    policy_version: u64,
262}
263
264struct McpConnection {
265    server: String,
266    transport: &'static str,
267    request_timeout: Duration,
268    shutdown_timeout: Duration,
269    max_request_bytes: usize,
270    max_result_bytes: usize,
271    peer_info: ServerPeerInfo,
272    service: RunningService<RoleClient, ClientInfo>,
273}
274
275/// A fixed collection of initialized MCP clients and their startup-discovered tools.
276pub struct McpClientSet {
277    runtime: Runtime,
278    connections: Vec<McpConnection>,
279    routes: BTreeMap<String, ToolRoute>,
280    schemas: Vec<Value>,
281    /// Monotonic authority token for route/server/exposed-tool/schema/policy
282    /// mutations. Admission captures and re-reads this token in addition to
283    /// the content digest at the publication boundary.
284    authority_generation: AtomicU64,
285}
286
287impl std::fmt::Debug for McpClientSet {
288    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        formatter
290            .debug_struct("McpClientSet")
291            .field("connections", &self.connections.len())
292            .field("routes", &self.routes.keys().collect::<Vec<_>>())
293            .finish_non_exhaustive()
294    }
295}
296
297impl McpClientSet {
298    /// Parse and validate configuration without starting servers or exposing configured values.
299    pub fn inspect_config_path(path: impl AsRef<Path>) -> Result<Value, McpError> {
300        let config = load_config(path.as_ref())?;
301        validate_config(&config)?;
302        let servers = config
303            .servers
304            .iter()
305            .map(|(name, server)| {
306                let (transport, required_environment) = match server {
307                    McpServerConfig::Stdio {
308                        env: server_env, ..
309                    } => (
310                        "stdio",
311                        server_env
312                            .keys()
313                            .map(|variable| {
314                                json!({"name": variable, "available": env::var_os(variable).is_some()})
315                            })
316                            .collect::<Vec<_>>(),
317                    ),
318                    McpServerConfig::StreamableHttp {
319                        bearer_token_env,
320                        header_env,
321                        ..
322                    } => {
323                        let mut variables = bearer_token_env.iter().collect::<Vec<_>>();
324                        variables.extend(header_env.values());
325                        variables.sort();
326                        variables.dedup();
327                        (
328                            "streamable_http",
329                            variables
330                                .into_iter()
331                                .map(|variable| {
332                                    json!({"name": variable, "available": env::var_os(variable).is_some()})
333                                })
334                                .collect::<Vec<_>>(),
335                        )
336                    }
337                };
338                json!({
339                    "name": name,
340                    "transport": transport,
341                    "required_environment": required_environment
342                })
343            })
344            .collect::<Vec<_>>();
345        Ok(json!({"version": config.version, "servers": servers}))
346    }
347
348    /// Parse configuration, initialize every server, negotiate capabilities, and discover tools.
349    pub fn from_config_path(path: impl AsRef<Path>) -> Result<Self, McpError> {
350        let config = load_config(path.as_ref())?;
351        Self::from_config(config)
352    }
353
354    fn from_config(config: McpConfig) -> Result<Self, McpError> {
355        validate_config(&config)?;
356        let runtime = tokio::runtime::Builder::new_multi_thread()
357            .enable_all()
358            .thread_name("falsegreen-agent-mcp")
359            .build()
360            .map_err(McpError::Runtime)?;
361        let mut connections = Vec::with_capacity(config.servers.len());
362        let mut routes = BTreeMap::new();
363        let mut schemas = Vec::new();
364
365        for (server, server_config) in config.servers {
366            let limits = server_config.limits();
367            let initialized = runtime.block_on(connect(&server, &server_config))?;
368            let connection_index = connections.len();
369            let mut server_schema_bytes = 0usize;
370            let mut remote_names = BTreeSet::new();
371            for tool in initialized.tools {
372                let remote_tool = tool.name.to_string();
373                if !remote_names.insert(remote_tool.clone()) {
374                    return Err(server_error(
375                        &server,
376                        "tools/list",
377                        format!("duplicate remote tool name {remote_tool:?}"),
378                    ));
379                }
380                let exposed_tool = namespaced_tool_name(&server, &remote_tool);
381                if routes.contains_key(&exposed_tool) {
382                    return Err(server_error(
383                        &server,
384                        "tools/list",
385                        format!("namespaced tool collision at {exposed_tool:?}"),
386                    ));
387                }
388                let schema = openai_schema(&server, &exposed_tool, &tool);
389                let schema_bytes = serde_json::to_vec(&schema)?.len();
390                server_schema_bytes = server_schema_bytes.saturating_add(schema_bytes);
391                if server_schema_bytes > limits.max_schema_bytes {
392                    return Err(server_error(
393                        &server,
394                        "tools/list",
395                        format!(
396                            "discovered tool schemas exceed the configured {} byte limit",
397                            limits.max_schema_bytes
398                        ),
399                    ));
400                }
401                // Remote hints are advisory only. Host policy defaults to
402                // consequential and can be relaxed by trusted integration code.
403                let policy = ToolPolicy::Consequential;
404                routes.insert(
405                    exposed_tool.clone(),
406                    ToolRoute {
407                        connection_index,
408                        server: server.clone(),
409                        remote_tool,
410                        exposed_tool,
411                        provider_id: provider_identity(&server, &server_config),
412                        policy,
413                        policy_version: 1,
414                    },
415                );
416                schemas.push(schema);
417            }
418            connections.push(McpConnection {
419                server,
420                transport: initialized.transport,
421                request_timeout: Duration::from_millis(limits.request_timeout_ms),
422                shutdown_timeout: Duration::from_millis(limits.shutdown_timeout_ms),
423                max_request_bytes: limits.max_request_bytes,
424                max_result_bytes: limits.max_result_bytes,
425                peer_info: initialized.peer_info,
426                service: initialized.service,
427            });
428        }
429
430        Ok(Self {
431            runtime,
432            connections,
433            routes,
434            schemas,
435            authority_generation: AtomicU64::new(1),
436        })
437    }
438
439    #[must_use]
440    pub fn tool_schemas(&self) -> &[Value] {
441        &self.schemas
442    }
443
444    /// Return the exact OpenAI-compatible schema retained for an exposed MCP
445    /// tool. The name is the collision-resistant host identity, not a model
446    /// supplied alias.
447    #[must_use]
448    pub fn tool_schema(&self, tool_name: &str) -> Option<&Value> {
449        self.schemas.iter().find(|schema| {
450            schema
451                .get("function")
452                .and_then(|function| function.get("name"))
453                .and_then(Value::as_str)
454                == Some(tool_name)
455        })
456    }
457
458    /// Build a trusted GenUI form from the original MCP input schema. This
459    /// does not grant permission or invoke the tool.
460    pub fn surface_for_tool(
461        &self,
462        surface_id: impl Into<String>,
463        tool_name: &str,
464        state_digest: impl Into<String>,
465    ) -> Result<Option<Surface>, McpError> {
466        let Some(schema) = self.tool_schema(tool_name) else {
467            return Ok(None);
468        };
469        let parameters = schema
470            .get("function")
471            .and_then(|function| function.get("parameters"))
472            .unwrap_or(schema);
473        mcp_tool_surface(surface_id, tool_name, parameters, state_digest)
474            .map(Some)
475            .map_err(genui_error)
476    }
477
478    /// Create a host-bound presentation and catalog entry for one discovered
479    /// route. The opaque action ID is generated by the host adapter, while the
480    /// provider/session/principal/schema identity is retained privately.
481    pub fn prepare_genui_surface(
482        &self,
483        _surface_id: impl Into<String>,
484        _session_id: &str,
485        _principal: &str,
486        _tool_name: &str,
487        _state_digest: impl Into<String>,
488    ) -> Result<Option<(Surface, ActionCatalog)>, McpError> {
489        if !self.routes.contains_key(_tool_name) {
490            return Ok(None);
491        }
492        Err(McpError::InvalidConfig(
493            "durable current state is required; use prepare_genui_surface_with_store".to_owned(),
494        ))
495    }
496
497    /// Create a host-bound GenUI presentation from the durable Agent/session
498    /// state. A caller-provided surface digest is never treated as current
499    /// authority; the state row is the only source for generation and digest.
500    pub fn prepare_genui_surface_with_store(
501        &self,
502        store: &EventStore,
503        surface_id: impl Into<String>,
504        session_id: &str,
505        principal: &str,
506        tool_name: &str,
507    ) -> Result<Option<(Surface, ActionCatalog)>, McpError> {
508        let Some(route) = self.routes.get(tool_name) else {
509            return Ok(None);
510        };
511        let Some(schema) = self.tool_schema(tool_name) else {
512            return Ok(None);
513        };
514        let parameters = schema
515            .get("function")
516            .and_then(|function| function.get("parameters"))
517            .unwrap_or(schema);
518        let (state_generation, state_digest, authorization_context) = store
519            .genui_current_state(session_id, principal)
520            .map_err(|error| McpError::InvalidConfig(error.to_string()))?
521            .ok_or_else(|| {
522                McpError::InvalidConfig(
523                    "no durable trusted current state exists for this session and principal"
524                        .to_owned(),
525                )
526            })?;
527        let mut surface = mcp_tool_surface(surface_id, tool_name, parameters, state_digest.clone())
528            .map_err(genui_error)?;
529        if route.policy == ToolPolicy::Consequential {
530            surface.actions[0].kind = crate::genui::ActionKind::Consequential;
531        }
532        let digest = surface.digest().map_err(genui_error)?;
533        let mut catalog = ActionCatalog::default();
534        let action = surface
535            .actions
536            .first()
537            .ok_or_else(|| McpError::InvalidConfig("MCP surface had no action".to_owned()))?;
538        catalog
539            .bind_mcp_action_with_context(
540                action,
541                &surface.id,
542                &digest,
543                session_id,
544                principal,
545                &authorization_context,
546                state_generation,
547                &state_digest,
548                &route.provider_id,
549                &route.server,
550                tool_name,
551                &mcp_schema_digest(parameters),
552                route.policy_version,
553                &route.policy.digest(route.policy_version),
554                route.policy.requires_confirmation(),
555                crate::genui::ActionSourceType::Mcp,
556            )
557            .map_err(genui_error)?;
558        catalog
559            .set_remote_tool_name(action.id.as_str(), &route.remote_tool)
560            .map_err(genui_error)?;
561        catalog
562            .restore_current_state(store, session_id, principal)
563            .map_err(genui_error)?;
564        Ok(Some((surface, catalog)))
565    }
566
567    /// Fail closed against the original MCP schema before a GenUI-bound call
568    /// is handed to the existing authenticated MCP transport.
569    pub fn validate_tool_arguments(
570        &self,
571        tool_name: &str,
572        arguments: &Value,
573    ) -> Result<(), McpError> {
574        let schema = self
575            .tool_schema(tool_name)
576            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
577        let parameters = schema
578            .get("function")
579            .and_then(|function| function.get("parameters"))
580            .unwrap_or(schema);
581        validate_mcp_submission(parameters, arguments).map_err(genui_error)
582    }
583
584    /// Canonical model-facing MCP gate. Read-only calls are schema-validated
585    /// and routed to the private transport seam; consequential calls return a
586    /// structured confirmation requirement and never reach the transport.
587    #[must_use]
588    pub fn execute_validated(&self, call: &ToolCall) -> Option<ToolResult> {
589        if self.may_mutate(&call.name) {
590            let mut result = ToolResult::rejected(
591                &call.name,
592                "host confirmation is required for consequential MCP tools",
593            );
594            result.metadata = json!({
595                "authority_state": "confirmation_required",
596                "confirmation_required": true,
597                "tool": call.name,
598            });
599            return Some(result);
600        }
601        self.execute_authorized(call)
602    }
603
604    /// Crate-private authority seam used only after the Agent/GenUI host has
605    /// revalidated policy, identity, state, confirmation, and lifecycle.
606    pub(crate) fn execute_validated_authorized(&self, call: &ToolCall) -> Option<ToolResult> {
607        self.execute_authorized(call)
608    }
609
610    /// Public compatibility entry point for ordinary read-only MCP calls.
611    /// It is an authority gate, never a raw transport executor.
612    #[must_use]
613    pub fn execute(&self, call: &ToolCall) -> Option<ToolResult> {
614        self.execute_validated(call)
615    }
616
617    #[must_use]
618    pub fn handles(&self, tool_name: &str) -> bool {
619        self.routes.contains_key(tool_name)
620    }
621
622    #[must_use]
623    pub fn may_mutate(&self, tool_name: &str) -> bool {
624        self.routes
625            .get(tool_name)
626            .is_some_and(|route| route.policy == ToolPolicy::Consequential)
627    }
628
629    pub fn set_tool_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<(), McpError> {
630        let _guard = acquire_publication_guard();
631        let route = self
632            .routes
633            .get_mut(tool_name)
634            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
635        route.policy = policy;
636        route.policy_version = route.policy_version.saturating_add(1);
637        self.bump_authority_generation();
638        Ok(())
639    }
640
641    /// Qualification-only confirmation-policy mutation. The execution route
642    /// derives confirmation from the live policy, so this exercises the same
643    /// authority transition used by the real confirmation gate.
644    #[doc(hidden)]
645    pub fn set_confirmation_requirement_for_testing(
646        &mut self,
647        tool_name: &str,
648        requires_confirmation: bool,
649    ) -> Result<(), McpError> {
650        self.set_tool_policy(
651            tool_name,
652            if requires_confirmation {
653                ToolPolicy::Consequential
654            } else {
655                ToolPolicy::ReadOnly
656            },
657        )
658    }
659
660    #[must_use]
661    pub fn tool_identity(&self, tool_name: &str) -> Option<(&str, &str, &str)> {
662        self.routes.get(tool_name).map(|route| {
663            (
664                route.provider_id.as_str(),
665                route.server.as_str(),
666                route.remote_tool.as_str(),
667            )
668        })
669    }
670
671    pub fn validate_tool_identity(
672        &self,
673        tool_name: &str,
674        provider_id: &str,
675        schema_digest: &str,
676    ) -> Result<(), McpError> {
677        let route = self
678            .routes
679            .get(tool_name)
680            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
681        self.validate_tool_identity_full(
682            tool_name,
683            provider_id,
684            &route.server,
685            schema_digest,
686            route.policy_version,
687            &route.policy.digest(route.policy_version),
688        )
689    }
690
691    pub fn validate_tool_identity_full(
692        &self,
693        tool_name: &str,
694        provider_id: &str,
695        server_id: &str,
696        schema_digest: &str,
697        policy_version: u64,
698        policy_digest: &str,
699    ) -> Result<(), McpError> {
700        let route = self
701            .routes
702            .get(tool_name)
703            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
704        if route.provider_id != provider_id {
705            return Err(McpError::InvalidConfig(
706                "MCP provider identity changed".to_owned(),
707            ));
708        }
709        if route.server != server_id {
710            return Err(McpError::InvalidConfig(
711                "MCP server identity changed".to_owned(),
712            ));
713        }
714        if route.policy_version != policy_version
715            || route.policy.digest(route.policy_version) != policy_digest
716        {
717            return Err(McpError::InvalidConfig(
718                "MCP tool policy changed".to_owned(),
719            ));
720        }
721        let schema = self
722            .tool_schema(tool_name)
723            .ok_or_else(|| McpError::InvalidConfig("MCP schema disappeared".to_owned()))?;
724        let parameters = schema
725            .get("function")
726            .and_then(|function| function.get("parameters"))
727            .unwrap_or(schema);
728        if mcp_schema_digest(parameters) != schema_digest {
729            return Err(McpError::InvalidConfig(
730                "MCP schema digest changed".to_owned(),
731            ));
732        }
733        Ok(())
734    }
735
736    #[must_use]
737    pub fn tool_policy_identity(&self, tool_name: &str) -> Option<(u64, String, bool)> {
738        self.routes.get(tool_name).map(|route| {
739            (
740                route.policy_version,
741                route.policy.digest(route.policy_version),
742                route.policy.requires_confirmation(),
743            )
744        })
745    }
746
747    /// Query the current route, schema, and policy identity from the same
748    /// registry used by execution. No cached GenUI/catalog state is consulted.
749    #[must_use]
750    pub fn live_tool_identity(&self, tool_name: &str) -> Option<LiveToolIdentity> {
751        let route = self.routes.get(tool_name)?;
752        let schema = self.tool_schema(tool_name)?;
753        let parameters = schema
754            .get("function")
755            .and_then(|function| function.get("parameters"))
756            .unwrap_or(schema);
757        Some(LiveToolIdentity {
758            exposed_tool: route.exposed_tool.clone(),
759            provider_id: route.provider_id.clone(),
760            server_id: route.server.clone(),
761            remote_tool_name: route.remote_tool.clone(),
762            schema_digest: mcp_schema_digest(parameters),
763            policy_version: route.policy_version,
764            policy_digest: route.policy.digest(route.policy_version),
765            requires_confirmation: route.policy.requires_confirmation(),
766        })
767    }
768
769    /// Stable digest of the live MCP registry/source authority.  It covers
770    /// every exposed route, remote identity, schema, policy, and confirmation
771    /// field used by admission and execution.
772    #[must_use]
773    pub fn live_registry_digest(&self) -> String {
774        let identities = self
775            .routes
776            .keys()
777            .filter_map(|name| self.live_tool_identity(name))
778            .collect::<Vec<_>>();
779        mcp_schema_digest(&serde_json::to_value(identities).unwrap_or_else(|_| json!([])))
780    }
781
782    /// Monotonic live-registry generation used as a publication CAS token.
783    /// The digest remains the authoritative content check; this token closes
784    /// ABA-style mutation windows where a route could be changed and restored
785    /// between two reads.
786    #[must_use]
787    pub fn live_registry_generation(&self) -> u64 {
788        self.authority_generation.load(Ordering::Acquire)
789    }
790
791    fn bump_authority_generation(&self) {
792        self.authority_generation.fetch_add(1, Ordering::AcqRel);
793    }
794
795    /// Qualification-only mutation seam for exercising the production live
796    /// admission fence. It changes the in-memory registry that execution and
797    /// `live_tool_identity` both read; it is never used by normal runtime code.
798    #[doc(hidden)]
799    pub fn mutate_live_identity_for_testing(
800        &mut self,
801        tool_name: &str,
802        provider_id: Option<&str>,
803        server_id: Option<&str>,
804        remote_tool_name: Option<&str>,
805        schema_digest: Option<&str>,
806    ) -> Result<(), McpError> {
807        let _guard = acquire_publication_guard();
808        let route = self
809            .routes
810            .get_mut(tool_name)
811            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
812        if let Some(provider_id) = provider_id {
813            route.provider_id = provider_id.to_owned();
814        }
815        if let Some(server_id) = server_id {
816            route.server = server_id.to_owned();
817        }
818        if let Some(remote_tool_name) = remote_tool_name {
819            route.remote_tool = remote_tool_name.to_owned();
820        }
821        if let Some(schema_digest) = schema_digest {
822            // Keep the schema structurally valid while changing its live
823            // digest. The caller supplies a hex identity to make the mutation
824            // deterministic; the actual schema payload remains host-owned.
825            if schema_digest.len() != 64
826                || !schema_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
827            {
828                return Err(McpError::InvalidConfig(
829                    "schema digest must be 64 hex characters".to_owned(),
830                ));
831            }
832            let schema = self
833                .schemas
834                .iter_mut()
835                .find(|schema| {
836                    schema
837                        .get("function")
838                        .and_then(|function| function.get("name"))
839                        .and_then(Value::as_str)
840                        == Some(tool_name)
841                })
842                .ok_or_else(|| McpError::InvalidConfig("MCP schema disappeared".to_owned()))?;
843            let function = schema
844                .get_mut("function")
845                .and_then(Value::as_object_mut)
846                .ok_or_else(|| McpError::InvalidConfig("MCP schema is malformed".to_owned()))?;
847            function.insert(
848                "parameters".to_owned(),
849                json!({"type": "object", "x-live-schema-digest": schema_digest}),
850            );
851        }
852        self.bump_authority_generation();
853        Ok(())
854    }
855
856    /// Qualification-only exposed-name rebinding. The old route disappears
857    /// from the live registry and the schema is renamed, which makes a handle
858    /// captured under the old exposed identity fail admission before any call.
859    #[doc(hidden)]
860    pub fn rebind_exposed_tool_for_testing(
861        &mut self,
862        old_tool_name: &str,
863        new_tool_name: &str,
864    ) -> Result<(), McpError> {
865        let _guard = acquire_publication_guard();
866        if self.routes.contains_key(new_tool_name) {
867            return Err(McpError::InvalidConfig(
868                "exposed tool name already exists".to_owned(),
869            ));
870        }
871        let mut route = self.routes.remove(old_tool_name).ok_or_else(|| {
872            McpError::InvalidConfig(format!("unknown MCP tool {old_tool_name:?}"))
873        })?;
874        let schema = self
875            .schemas
876            .iter_mut()
877            .find(|schema| {
878                schema
879                    .get("function")
880                    .and_then(|function| function.get("name"))
881                    .and_then(Value::as_str)
882                    == Some(old_tool_name)
883            })
884            .ok_or_else(|| McpError::InvalidConfig("MCP schema disappeared".to_owned()))?;
885        let function = schema
886            .get_mut("function")
887            .and_then(Value::as_object_mut)
888            .ok_or_else(|| McpError::InvalidConfig("MCP schema is malformed".to_owned()))?;
889        function.insert("name".to_owned(), Value::String(new_tool_name.to_owned()));
890        route.exposed_tool = new_tool_name.to_owned();
891        self.routes.insert(new_tool_name.to_owned(), route);
892        self.bump_authority_generation();
893        Ok(())
894    }
895
896    /// Qualification-only route deletion used to prove missing-route
897    /// fail-closed admission.  The old route and its schema both disappear
898    /// from the live registry used by transport.
899    #[doc(hidden)]
900    pub fn remove_live_route_for_testing(&mut self, tool_name: &str) -> Result<(), McpError> {
901        let _guard = acquire_publication_guard();
902        self.routes
903            .remove(tool_name)
904            .ok_or_else(|| McpError::InvalidConfig(format!("unknown MCP tool {tool_name:?}")))?;
905        self.schemas.retain(|schema| {
906            schema
907                .get("function")
908                .and_then(|function| function.get("name"))
909                .and_then(Value::as_str)
910                != Some(tool_name)
911        });
912        self.bump_authority_generation();
913        Ok(())
914    }
915
916    /// Return non-secret startup provenance suitable for durable event history.
917    #[must_use]
918    pub fn discovery_metadata(&self) -> Value {
919        let servers = self
920            .connections
921            .iter()
922            .enumerate()
923            .map(|(connection_index, connection)| {
924                let tools = self
925                    .routes
926                    .values()
927                    .filter(|route| route.connection_index == connection_index)
928                    .map(|route| {
929                        let schema_status = self
930                            .tool_schema(&route.exposed_tool)
931                            .and_then(|schema| {
932                                schema
933                                    .get("function")
934                                    .and_then(|function| function.get("parameters"))
935                                    .or(Some(schema))
936                            })
937                            .map(|parameters| {
938                                let source_digest = mcp_schema_digest(parameters);
939                                match adapt_mcp_schema(parameters) {
940                                    Ok(ir) => json!({
941                                        "supported": true,
942                                        "adapter": ir.adapter,
943                                        "field_count": ir.fields.len(),
944                                        "source_digest": source_digest
945                                    }),
946                                    Err(error) => json!({
947                                        "supported": false,
948                                        "source_digest": source_digest,
949                                        "reason": error.to_string()
950                                    }),
951                                }
952                            })
953                            .unwrap_or_else(|| {
954                                json!({
955                                    "supported": false,
956                                    "reason": "schema_missing"
957                                })
958                            });
959                        json!({
960                            "remote_tool": route.remote_tool,
961                            "exposed_tool": route.exposed_tool,
962                            "policy": route.policy.as_str(),
963                            "policy_version": route.policy_version,
964                            "provider_id": route.provider_id,
965                            "schema_status": schema_status
966                        })
967                    })
968                    .collect::<Vec<_>>();
969                json!({
970                    "server": connection.server,
971                    "transport": connection.transport,
972                    "protocol_version": connection.peer_info.protocol_version,
973                    "server_info": connection.peer_info.server_info,
974                    "capabilities": connection.peer_info.capabilities,
975                    "tools": tools
976                })
977            })
978            .collect::<Vec<_>>();
979        json!({"sdk": "rmcp", "sdk_version": "3.3.0", "servers": servers})
980    }
981
982    fn execute_authorized(&self, call: &ToolCall) -> Option<ToolResult> {
983        let route = self.routes.get(&call.name)?;
984        if let Err(error) = self.validate_tool_arguments(&call.name, &call.arguments) {
985            return Some(ToolResult::rejected(&call.name, &error.to_string()));
986        }
987        let connection = &self.connections[route.connection_index];
988        let argument_bytes = match serde_json::to_vec(&call.arguments) {
989            Ok(bytes) => bytes,
990            Err(error) => {
991                return Some(error_result(
992                    route,
993                    format!("MCP arguments could not be serialized: {error}"),
994                    0,
995                    None,
996                ));
997            }
998        };
999        let arguments = match call.arguments.as_object() {
1000            Some(arguments) => arguments.clone(),
1001            None => {
1002                return Some(error_result(
1003                    route,
1004                    "MCP arguments must be a JSON object".to_owned(),
1005                    argument_bytes.len(),
1006                    Some(sha256_hex(&argument_bytes)),
1007                ));
1008            }
1009        };
1010        if argument_bytes.len() > connection.max_request_bytes {
1011            return Some(error_result(
1012                route,
1013                format!(
1014                    "MCP arguments exceed the configured {} byte limit",
1015                    connection.max_request_bytes
1016                ),
1017                argument_bytes.len(),
1018                Some(sha256_hex(&argument_bytes)),
1019            ));
1020        }
1021
1022        let result = self.runtime.block_on(call_tool(
1023            &connection.service,
1024            &route.remote_tool,
1025            arguments,
1026            connection.request_timeout,
1027        ));
1028        Some(match result {
1029            Ok(result) => {
1030                bounded_result(route, &argument_bytes, result, connection.max_result_bytes)
1031            }
1032            Err(error) => error_result(
1033                route,
1034                format!("MCP tools/call failed: {error}"),
1035                argument_bytes.len(),
1036                Some(sha256_hex(&argument_bytes)),
1037            ),
1038        })
1039    }
1040}
1041
1042fn genui_error(error: GenUiError) -> McpError {
1043    McpError::InvalidConfig(format!("GenUI schema/action validation failed: {error}"))
1044}
1045
1046fn load_config(path: &Path) -> Result<McpConfig, McpError> {
1047    let bytes = fs::read(path).map_err(|source| McpError::ConfigIo {
1048        path: path.display().to_string(),
1049        source,
1050    })?;
1051    if bytes.len() > MAX_CONFIG_BYTES {
1052        return Err(McpError::ConfigTooLarge);
1053    }
1054    serde_json::from_slice(&bytes).map_err(McpError::from)
1055}
1056
1057impl Drop for McpClientSet {
1058    fn drop(&mut self) {
1059        for connection in &mut self.connections {
1060            let _ = self.runtime.block_on(
1061                connection
1062                    .service
1063                    .close_with_timeout(connection.shutdown_timeout),
1064            );
1065        }
1066    }
1067}
1068
1069struct InitializedConnection {
1070    transport: &'static str,
1071    peer_info: ServerPeerInfo,
1072    tools: Vec<Tool>,
1073    service: RunningService<RoleClient, ClientInfo>,
1074}
1075
1076async fn connect(
1077    server: &str,
1078    config: &McpServerConfig,
1079) -> Result<InitializedConnection, McpError> {
1080    let limits = config.limits();
1081    let client_info = ClientInfo::new(
1082        ClientCapabilities::default(),
1083        Implementation::new("falsegreen-agent", env!("CARGO_PKG_VERSION")),
1084    );
1085    let startup_timeout = Duration::from_millis(limits.startup_timeout_ms);
1086    let (transport, service) = match config {
1087        McpServerConfig::Stdio {
1088            command,
1089            args,
1090            env,
1091            cwd,
1092            ..
1093        } => {
1094            let mut command_process = tokio::process::Command::new(command);
1095            command_process
1096                .args(args)
1097                .envs(env)
1098                .stdin(Stdio::piped())
1099                .stdout(Stdio::piped())
1100                .stderr(Stdio::inherit())
1101                .kill_on_drop(true);
1102            if let Some(cwd) = cwd {
1103                command_process.current_dir(cwd);
1104            }
1105            let transport = TokioChildProcess::new(command_process)
1106                .map_err(|error| server_error(server, "spawn", error.to_string()))?;
1107            let service = tokio::time::timeout(startup_timeout, client_info.serve(transport))
1108                .await
1109                .map_err(|_| {
1110                    server_error(
1111                        server,
1112                        "initialize",
1113                        format!("timed out after {} ms", limits.startup_timeout_ms),
1114                    )
1115                })?
1116                .map_err(|error| server_error(server, "initialize", error.to_string()))?;
1117            ("stdio", service)
1118        }
1119        McpServerConfig::StreamableHttp {
1120            url,
1121            bearer_token_env,
1122            headers,
1123            header_env,
1124            ..
1125        } => {
1126            let mut custom_headers = HashMap::new();
1127            for (name, value) in headers {
1128                insert_header(server, &mut custom_headers, name, value)?;
1129            }
1130            for (name, variable) in header_env {
1131                let value = env::var(variable).map_err(|error| {
1132                    server_error(
1133                        server,
1134                        "configuration",
1135                        format!("environment variable {variable:?} is unavailable: {error}"),
1136                    )
1137                })?;
1138                insert_header(server, &mut custom_headers, name, &value)?;
1139            }
1140            let mut transport_config = StreamableHttpClientTransportConfig::with_uri(url.clone())
1141                .custom_headers(custom_headers)
1142                .max_concurrent_requests(1)
1143                .control_request_timeout(Duration::from_millis(limits.shutdown_timeout_ms))
1144                .session_recovery_timeout(startup_timeout)
1145                .max_sse_event_size(limits.max_result_bytes);
1146            if let Some(variable) = bearer_token_env {
1147                let token = env::var(variable).map_err(|error| {
1148                    server_error(
1149                        server,
1150                        "configuration",
1151                        format!("environment variable {variable:?} is unavailable: {error}"),
1152                    )
1153                })?;
1154                transport_config = transport_config.auth_header(token);
1155            }
1156            let transport = StreamableHttpClientTransport::from_config(transport_config);
1157            let service = tokio::time::timeout(startup_timeout, client_info.serve(transport))
1158                .await
1159                .map_err(|_| {
1160                    server_error(
1161                        server,
1162                        "initialize",
1163                        format!("timed out after {} ms", limits.startup_timeout_ms),
1164                    )
1165                })?
1166                .map_err(|error| server_error(server, "initialize", error.to_string()))?;
1167            ("streamable_http", service)
1168        }
1169    };
1170
1171    let peer_info = service
1172        .peer_info()
1173        .ok_or_else(|| server_error(server, "initialize", "server returned no peer information"))?;
1174    if peer_info.capabilities.tools.is_none() {
1175        return Err(server_error(
1176            server,
1177            "capability negotiation",
1178            "server did not advertise tools capability",
1179        ));
1180    }
1181    let tools = list_all_tools(
1182        &service,
1183        server,
1184        Duration::from_millis(limits.request_timeout_ms),
1185    )
1186    .await?;
1187    Ok(InitializedConnection {
1188        transport,
1189        peer_info: (*peer_info).clone(),
1190        tools,
1191        service,
1192    })
1193}
1194
1195async fn list_all_tools(
1196    service: &RunningService<RoleClient, ClientInfo>,
1197    server: &str,
1198    timeout: Duration,
1199) -> Result<Vec<Tool>, McpError> {
1200    let mut cursor = None;
1201    let mut tools = Vec::new();
1202    for _ in 0..MAX_LIST_PAGES {
1203        let params = PaginatedRequestParams::default().with_cursor(cursor);
1204        let request = ClientRequest::ListToolsRequest(ListToolsRequest::with_param(params));
1205        let response = service
1206            .send_cancellable_request(request, PeerRequestOptions::with_timeout(timeout))
1207            .await
1208            .map_err(|error| server_error(server, "tools/list", error.to_string()))?
1209            .await_response()
1210            .await
1211            .map_err(|error| server_error(server, "tools/list", error.to_string()))?;
1212        let ServerResult::ListToolsResult(result) = response else {
1213            return Err(server_error(
1214                server,
1215                "tools/list",
1216                "server returned an unexpected response type",
1217            ));
1218        };
1219        tools.extend(result.tools);
1220        if tools.len() > MAX_TOOLS_PER_SERVER {
1221            return Err(server_error(
1222                server,
1223                "tools/list",
1224                format!("server exceeds the {MAX_TOOLS_PER_SERVER} tool limit"),
1225            ));
1226        }
1227        cursor = result.next_cursor;
1228        if cursor.is_none() {
1229            return Ok(tools);
1230        }
1231    }
1232    Err(server_error(
1233        server,
1234        "tools/list",
1235        format!("pagination exceeds the {MAX_LIST_PAGES} page limit"),
1236    ))
1237}
1238
1239async fn call_tool(
1240    service: &RunningService<RoleClient, ClientInfo>,
1241    remote_tool: &str,
1242    arguments: Map<String, Value>,
1243    timeout: Duration,
1244) -> Result<rmcp::model::CallToolResult, rmcp::ServiceError> {
1245    let params = CallToolRequestParams::new(remote_tool.to_owned()).with_arguments(arguments);
1246    let request = ClientRequest::CallToolRequest(CallToolRequest::new(params));
1247    let response = service
1248        .send_cancellable_request(request, PeerRequestOptions::with_timeout(timeout))
1249        .await?
1250        .await_response()
1251        .await?;
1252    match response {
1253        ServerResult::CallToolResult(result) => Ok(result),
1254        _ => Err(rmcp::ServiceError::UnexpectedResponse),
1255    }
1256}
1257
1258fn validate_config(config: &McpConfig) -> Result<(), McpError> {
1259    if config.version != CONFIG_VERSION {
1260        return Err(McpError::InvalidConfig(format!(
1261            "version must be {CONFIG_VERSION}, found {}",
1262            config.version
1263        )));
1264    }
1265    if config.servers.len() > MAX_SERVERS {
1266        return Err(McpError::InvalidConfig(format!(
1267            "server count exceeds the {MAX_SERVERS} server limit"
1268        )));
1269    }
1270    for (name, server) in &config.servers {
1271        let limits = server.limits();
1272        if name.is_empty() || name.len() > 128 || name.contains('\0') {
1273            return Err(McpError::InvalidConfig(format!(
1274                "server name {name:?} must contain 1-128 non-NUL bytes"
1275            )));
1276        }
1277        validate_bounded_value(
1278            name,
1279            "startup_timeout_ms",
1280            limits.startup_timeout_ms,
1281            MAX_TIMEOUT_MS,
1282        )?;
1283        validate_bounded_value(
1284            name,
1285            "request_timeout_ms",
1286            limits.request_timeout_ms,
1287            MAX_TIMEOUT_MS,
1288        )?;
1289        validate_bounded_value(
1290            name,
1291            "shutdown_timeout_ms",
1292            limits.shutdown_timeout_ms,
1293            MAX_TIMEOUT_MS,
1294        )?;
1295        validate_bounded_value(
1296            name,
1297            "max_request_bytes",
1298            limits.max_request_bytes,
1299            MAX_REQUEST_BYTES_HARD,
1300        )?;
1301        validate_bounded_value(
1302            name,
1303            "max_result_bytes",
1304            limits.max_result_bytes,
1305            MAX_RESULT_BYTES_HARD,
1306        )?;
1307        validate_bounded_value(
1308            name,
1309            "max_schema_bytes",
1310            limits.max_schema_bytes,
1311            MAX_SCHEMA_BYTES_HARD,
1312        )?;
1313        match server {
1314            McpServerConfig::Stdio {
1315                command,
1316                args,
1317                env,
1318                cwd,
1319                ..
1320            } => {
1321                if command.is_empty() || command.contains('\0') {
1322                    return Err(McpError::InvalidConfig(format!(
1323                        "server {name:?} command must be non-empty and contain no NUL"
1324                    )));
1325                }
1326                if args.iter().any(|arg| arg.contains('\0')) {
1327                    return Err(McpError::InvalidConfig(format!(
1328                        "server {name:?} arguments must contain no NUL"
1329                    )));
1330                }
1331                if env.iter().any(|(key, value)| {
1332                    key.is_empty() || key.contains(['=', '\0']) || value.contains('\0')
1333                }) {
1334                    return Err(McpError::InvalidConfig(format!(
1335                        "server {name:?} environment contains an invalid key or value"
1336                    )));
1337                }
1338                if cwd.as_ref().is_some_and(|path| !path.is_dir()) {
1339                    return Err(McpError::InvalidConfig(format!(
1340                        "server {name:?} cwd does not name an existing directory"
1341                    )));
1342                }
1343            }
1344            McpServerConfig::StreamableHttp {
1345                url,
1346                bearer_token_env,
1347                headers,
1348                header_env,
1349                ..
1350            } => {
1351                validate_http_url(name, url)?;
1352                if bearer_token_env
1353                    .as_ref()
1354                    .is_some_and(|variable| variable.is_empty() || variable.contains(['=', '\0']))
1355                {
1356                    return Err(McpError::InvalidConfig(format!(
1357                        "server {name:?} bearer_token_env is invalid"
1358                    )));
1359                }
1360                for header_name in headers.keys().chain(header_env.keys()) {
1361                    HeaderName::try_from(header_name).map_err(|error| {
1362                        McpError::InvalidConfig(format!(
1363                            "server {name:?} has invalid HTTP header name {header_name:?}: {error}"
1364                        ))
1365                    })?;
1366                }
1367                if header_env
1368                    .values()
1369                    .any(|variable| variable.is_empty() || variable.contains(['=', '\0']))
1370                {
1371                    return Err(McpError::InvalidConfig(format!(
1372                        "server {name:?} header_env contains an invalid environment variable"
1373                    )));
1374                }
1375            }
1376        }
1377    }
1378    Ok(())
1379}
1380
1381fn validate_bounded_value<T>(
1382    server: &str,
1383    field: &str,
1384    value: T,
1385    maximum: T,
1386) -> Result<(), McpError>
1387where
1388    T: Copy + Ord + std::fmt::Display + From<u8>,
1389{
1390    if value < T::from(1) || value > maximum {
1391        return Err(McpError::InvalidConfig(format!(
1392            "server {server:?} {field} must be between 1 and {maximum}, found {value}"
1393        )));
1394    }
1395    Ok(())
1396}
1397
1398fn validate_http_url(server: &str, raw: &str) -> Result<(), McpError> {
1399    let url = Url::parse(raw).map_err(|error| {
1400        McpError::InvalidConfig(format!("server {server:?} URL is invalid: {error}"))
1401    })?;
1402    if !matches!(url.scheme(), "http" | "https")
1403        || url.host_str().is_none()
1404        || !url.username().is_empty()
1405        || url.password().is_some()
1406        || url.fragment().is_some()
1407    {
1408        return Err(McpError::InvalidConfig(format!(
1409            "server {server:?} URL must be absolute HTTP(S) without userinfo or fragment"
1410        )));
1411    }
1412    Ok(())
1413}
1414
1415fn insert_header(
1416    server: &str,
1417    target: &mut HashMap<HeaderName, HeaderValue>,
1418    name: &str,
1419    value: &str,
1420) -> Result<(), McpError> {
1421    let name = HeaderName::try_from(name).map_err(|error| {
1422        server_error(
1423            server,
1424            "configuration",
1425            format!("invalid HTTP header name: {error}"),
1426        )
1427    })?;
1428    let value = HeaderValue::try_from(value).map_err(|error| {
1429        server_error(
1430            server,
1431            "configuration",
1432            format!("invalid value for HTTP header {name}: {error}"),
1433        )
1434    })?;
1435    if target.insert(name.clone(), value).is_some() {
1436        return Err(server_error(
1437            server,
1438            "configuration",
1439            format!("duplicate HTTP header {name}"),
1440        ));
1441    }
1442    Ok(())
1443}
1444
1445fn namespaced_tool_name(server: &str, remote_tool: &str) -> String {
1446    let server_slug = slug(server, 16);
1447    let tool_slug = slug(remote_tool, 24);
1448    let mut digest = Sha256::new();
1449    digest.update(server.as_bytes());
1450    digest.update([0]);
1451    digest.update(remote_tool.as_bytes());
1452    let suffix = format!("{:x}", digest.finalize());
1453    format!("mcp__{server_slug}__{tool_slug}__{}", &suffix[..8])
1454}
1455
1456fn provider_identity(server: &str, config: &McpServerConfig) -> String {
1457    let descriptor = match config {
1458        McpServerConfig::Stdio {
1459            command, args, cwd, ..
1460        } => format!("{server}:stdio:{command}:{args:?}:{cwd:?}"),
1461        McpServerConfig::StreamableHttp { url, .. } => format!("{server}:streamable_http:{url}"),
1462    };
1463    format!("provider_{}", sha256_hex(descriptor.as_bytes()))
1464}
1465
1466fn slug(value: &str, maximum: usize) -> String {
1467    let mut output = String::new();
1468    for character in value.chars() {
1469        if output.len() >= maximum {
1470            break;
1471        }
1472        let character = if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
1473            character.to_ascii_lowercase()
1474        } else {
1475            '_'
1476        };
1477        output.push(character);
1478    }
1479    if output.is_empty() {
1480        output.push('_');
1481    }
1482    output
1483}
1484
1485fn openai_schema(server: &str, exposed_tool: &str, tool: &Tool) -> Value {
1486    let description = tool.description.as_deref().map_or_else(
1487        || {
1488            format!(
1489                "MCP tool {:?} from configured server {:?}",
1490                tool.name, server
1491            )
1492        },
1493        |description| format!("MCP server {server:?}: {description}"),
1494    );
1495    let description = truncate_schema_description(description, 160);
1496    // Preserve the exact server-provided schema. Presentation code may derive
1497    // a projection, but identity and invocation validation must use this value
1498    // byte-for-byte (under canonical JSON serialization).
1499    let parameters = Value::Object(tool.input_schema.as_ref().clone());
1500    json!({
1501        "type": "function",
1502        "function": {
1503            "name": exposed_tool,
1504            "description": description,
1505            "parameters": parameters
1506        }
1507    })
1508}
1509
1510fn truncate_schema_description(description: String, maximum_chars: usize) -> String {
1511    if description.chars().count() <= maximum_chars {
1512        return description;
1513    }
1514    let mut truncated = description.chars().take(maximum_chars).collect::<String>();
1515    truncated.push('…');
1516    truncated
1517}
1518
1519/// Legacy presentation helper retained for callers that explicitly request a
1520/// compact projection. It is deliberately not used for tool identity or
1521/// invocation validation; those paths retain the exact original schema.
1522#[allow(dead_code)]
1523fn remove_nonsemantic_schema_annotations(value: &mut Value) {
1524    match value {
1525        Value::Object(object) => {
1526            object.remove("title");
1527            object.remove("default");
1528            object.remove("examples");
1529            for child in object.values_mut() {
1530                remove_nonsemantic_schema_annotations(child);
1531            }
1532        }
1533        Value::Array(items) => {
1534            for item in items {
1535                remove_nonsemantic_schema_annotations(item);
1536            }
1537        }
1538        _ => {}
1539    }
1540}
1541
1542fn bounded_result(
1543    route: &ToolRoute,
1544    argument_bytes: &[u8],
1545    result: rmcp::model::CallToolResult,
1546    maximum: usize,
1547) -> ToolResult {
1548    let serialized = match serde_json::to_vec(&result) {
1549        Ok(serialized) => serialized,
1550        Err(error) => {
1551            return error_result(
1552                route,
1553                format!("MCP result could not be serialized: {error}"),
1554                argument_bytes.len(),
1555                Some(sha256_hex(argument_bytes)),
1556            );
1557        }
1558    };
1559    let result_sha256 = sha256_hex(&serialized);
1560    let result_bytes = serialized.len();
1561    let is_error = result.is_error == Some(true);
1562    if result_bytes > maximum {
1563        return ToolResult {
1564            tool: route.exposed_tool.clone(),
1565            tool_call_id: None,
1566            ok: false,
1567            exit_code: None,
1568            timed_out: false,
1569            stdout: String::new(),
1570            stderr: format!("MCP result exceeds the configured {maximum} byte limit"),
1571            output_truncated: true,
1572            metadata: provenance(
1573                route,
1574                argument_bytes.len(),
1575                Some(sha256_hex(argument_bytes)),
1576                Some(result_bytes),
1577                Some(result_sha256),
1578                Some(is_error),
1579                Some("result_too_large"),
1580            ),
1581        };
1582    }
1583    ToolResult {
1584        tool: route.exposed_tool.clone(),
1585        tool_call_id: None,
1586        ok: !is_error,
1587        exit_code: None,
1588        timed_out: false,
1589        stdout: String::from_utf8(serialized).expect("serde_json emits UTF-8"),
1590        stderr: if is_error {
1591            "MCP tool returned isError=true".to_owned()
1592        } else {
1593            String::new()
1594        },
1595        output_truncated: false,
1596        metadata: provenance(
1597            route,
1598            argument_bytes.len(),
1599            Some(sha256_hex(argument_bytes)),
1600            Some(result_bytes),
1601            Some(result_sha256),
1602            Some(is_error),
1603            None,
1604        ),
1605    }
1606}
1607
1608fn error_result(
1609    route: &ToolRoute,
1610    message: String,
1611    argument_bytes: usize,
1612    argument_sha256: Option<String>,
1613) -> ToolResult {
1614    let timed_out = message.to_ascii_lowercase().contains("timeout")
1615        || message.to_ascii_lowercase().contains("timed out");
1616    let (message, output_truncated) = truncate_utf8(message, MAX_ERROR_BYTES);
1617    ToolResult {
1618        tool: route.exposed_tool.clone(),
1619        tool_call_id: None,
1620        ok: false,
1621        exit_code: None,
1622        timed_out,
1623        stdout: String::new(),
1624        stderr: message,
1625        output_truncated,
1626        metadata: provenance(
1627            route,
1628            argument_bytes,
1629            argument_sha256,
1630            None,
1631            None,
1632            None,
1633            Some("request_failed"),
1634        ),
1635    }
1636}
1637
1638fn truncate_utf8(mut value: String, maximum: usize) -> (String, bool) {
1639    if value.len() <= maximum {
1640        return (value, false);
1641    }
1642    let mut end = maximum;
1643    while !value.is_char_boundary(end) {
1644        end -= 1;
1645    }
1646    value.truncate(end);
1647    (value, true)
1648}
1649
1650#[allow(clippy::too_many_arguments)]
1651fn provenance(
1652    route: &ToolRoute,
1653    argument_bytes: usize,
1654    argument_sha256: Option<String>,
1655    result_bytes: Option<usize>,
1656    result_sha256: Option<String>,
1657    is_error: Option<bool>,
1658    error_kind: Option<&str>,
1659) -> Value {
1660    json!({
1661        "transport_protocol": "mcp",
1662        "mcp_server": route.server,
1663        "mcp_remote_tool": route.remote_tool,
1664        "mcp_exposed_tool": route.exposed_tool,
1665        "argument_bytes": argument_bytes,
1666        "argument_sha256": argument_sha256,
1667        "result_bytes": result_bytes,
1668        "result_sha256": result_sha256,
1669        "mcp_is_error": is_error,
1670        "error_kind": error_kind
1671    })
1672}
1673
1674fn sha256_hex(bytes: &[u8]) -> String {
1675    format!("{:x}", Sha256::digest(bytes))
1676}
1677
1678fn server_error(server: &str, phase: &'static str, message: impl Into<String>) -> McpError {
1679    McpError::Server {
1680        server: server.to_owned(),
1681        phase,
1682        message: message.into(),
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use std::collections::BTreeMap;
1689
1690    use serde_json::json;
1691
1692    use super::{
1693        CONFIG_VERSION, McpConfig, McpServerConfig, namespaced_tool_name,
1694        remove_nonsemantic_schema_annotations, truncate_schema_description, validate_config,
1695    };
1696
1697    fn stdio_config() -> McpConfig {
1698        McpConfig {
1699            version: CONFIG_VERSION,
1700            servers: BTreeMap::from([(
1701                "False Green".to_owned(),
1702                McpServerConfig::Stdio {
1703                    command: "falsegreen-mcp".to_owned(),
1704                    args: Vec::new(),
1705                    env: BTreeMap::new(),
1706                    cwd: None,
1707                    startup_timeout_ms: 1_000,
1708                    request_timeout_ms: 1_000,
1709                    shutdown_timeout_ms: 1_000,
1710                    max_request_bytes: 1_024,
1711                    max_result_bytes: 1_024,
1712                    max_schema_bytes: 1_024,
1713                },
1714            )]),
1715        }
1716    }
1717
1718    #[test]
1719    fn namespace_is_stable_safe_and_disambiguated() {
1720        let first = namespaced_tool_name("False Green", "verify/task");
1721        assert_eq!(first, namespaced_tool_name("False Green", "verify/task"));
1722        assert!(first.starts_with("mcp__false_green__verify_task__"));
1723        assert!(
1724            first
1725                .bytes()
1726                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1727        );
1728        assert_ne!(
1729            namespaced_tool_name("a__b", "c"),
1730            namespaced_tool_name("a", "b__c")
1731        );
1732        assert!(first.len() <= 64);
1733    }
1734
1735    #[test]
1736    fn config_rejects_unbounded_values_and_unknown_fields() {
1737        let mut config = stdio_config();
1738        match config.servers.get_mut("False Green").unwrap() {
1739            McpServerConfig::Stdio {
1740                request_timeout_ms, ..
1741            }
1742            | McpServerConfig::StreamableHttp {
1743                request_timeout_ms, ..
1744            } => *request_timeout_ms = 0,
1745        }
1746        assert!(validate_config(&config).is_err());
1747
1748        let unknown = json!({
1749            "version": 1,
1750            "servers": {"s": {"transport": "stdio", "command": "x", "surprise": true}}
1751        });
1752        assert!(serde_json::from_value::<McpConfig>(unknown).is_err());
1753    }
1754
1755    #[test]
1756    fn config_rejects_credential_bearing_url() {
1757        let value = json!({
1758            "version": 1,
1759            "servers": {
1760                "remote": {
1761                    "transport": "streamable_http",
1762                    "url": "https://user:secret@example.invalid/mcp"
1763                }
1764            }
1765        });
1766        let config: McpConfig = serde_json::from_value(value).expect("shape");
1767        assert!(validate_config(&config).is_err());
1768    }
1769
1770    #[test]
1771    fn model_schema_compaction_preserves_validation_shape() {
1772        let mut schema = json!({
1773            "type": "object",
1774            "title": "Arguments",
1775            "description": "semantics",
1776            "properties": {
1777                "task_id": {
1778                    "type": "string",
1779                    "title": "Task",
1780                    "default": "unsafe-default",
1781                    "description": "The canonical task identifier."
1782                }
1783            },
1784            "required": ["task_id"]
1785        });
1786        remove_nonsemantic_schema_annotations(&mut schema);
1787        assert_eq!(schema["type"], "object");
1788        assert_eq!(schema["required"], json!(["task_id"]));
1789        assert_eq!(schema["properties"]["task_id"]["type"], "string");
1790        assert_eq!(
1791            schema["properties"]["task_id"]["description"],
1792            "The canonical task identifier."
1793        );
1794        assert!(schema.get("title").is_none());
1795        assert!(schema["properties"]["task_id"].get("default").is_none());
1796        assert!(
1797            truncate_schema_description("x".repeat(300), 160)
1798                .chars()
1799                .count()
1800                <= 161
1801        );
1802    }
1803}