1use 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#[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
275pub struct McpClientSet {
277 runtime: Runtime,
278 connections: Vec<McpConnection>,
279 routes: BTreeMap<String, ToolRoute>,
280 schemas: Vec<Value>,
281 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 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 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 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 #[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 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 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 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 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 #[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 pub(crate) fn execute_validated_authorized(&self, call: &ToolCall) -> Option<ToolResult> {
607 self.execute_authorized(call)
608 }
609
610 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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#[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}