1use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use serde_json::{json, Value};
27use thiserror::Error;
28
29use super::{McpConfig, McpServerConfig};
30use crate::tools::catalog::SchemaDigest;
31use crate::tools::{Tool, ToolContext, ToolOrigin};
32use agent_core::BoundedText;
33
34pub const DESCRIPTOR_CACHE_FILE: &str = "mcp-descriptors.json";
37pub const CACHE_FORMAT_VERSION: u32 = 1;
39pub const CACHE_MAX_BYTES: u64 = 1024 * 1024;
41pub const SERVER_MAX_TOOLS: usize = 256;
43pub const TOOL_NAME_MAX_BYTES: usize = 128;
45pub const TOOL_DESCRIPTION_MAX_BYTES: usize = 1024;
47pub const TOOL_SCHEMA_MAX_BYTES: usize = 64 * 1024;
49const SERVER_NAME_MAX_BYTES: usize = 64;
51const ERROR_ECHO_MAX_BYTES: usize = 160;
53
54pub fn server_config_fingerprint(config: &McpServerConfig) -> String {
59 let env: BTreeMap<&str, &str> = config
60 .env
61 .iter()
62 .map(|(k, v)| (k.as_str(), v.as_str()))
63 .collect();
64 SchemaDigest::of_schema(&json!({
65 "command": config.command,
66 "args": config.args,
67 "env": env,
68 }))
69 .as_hex()
70 .to_string()
71}
72
73#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
77pub struct CachedToolDescriptor {
78 pub name: String,
79 #[serde(default)]
80 pub description: String,
81 pub input_schema: Value,
82}
83
84#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
87pub struct CachedServerDescriptors {
88 pub fingerprint: String,
89 #[serde(default)]
90 pub tools: Vec<CachedToolDescriptor>,
91}
92
93#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
95pub struct McpDescriptorCache {
96 pub version: u32,
97 #[serde(default)]
98 pub servers: BTreeMap<String, CachedServerDescriptors>,
99}
100
101impl McpDescriptorCache {
102 pub fn empty() -> Self {
103 Self {
104 version: CACHE_FORMAT_VERSION,
105 servers: BTreeMap::new(),
106 }
107 }
108}
109
110#[derive(Debug, Error)]
113pub enum DescriptorCacheError {
114 #[error("descriptor cache not found")]
115 NotFound,
116 #[error("descriptor cache is not a regular file (symlinks and special files are rejected)")]
117 NotRegularFile,
118 #[error("descriptor cache is {actual} bytes, over the {max} byte bound")]
119 Oversize { actual: u64, max: u64 },
120 #[error("failed to read descriptor cache: {0}")]
121 Io(String),
122 #[error("descriptor cache is not valid for the expected shape: {0}")]
123 Parse(String),
124 #[error("unsupported descriptor cache version {0}")]
125 Version(u32),
126}
127
128fn valid_tool_name(name: &str) -> bool {
129 !name.is_empty() && name.len() <= TOOL_NAME_MAX_BYTES && !name.chars().any(char::is_control)
130}
131
132fn valid_server_name(name: &str) -> bool {
133 !name.is_empty() && name.len() <= SERVER_NAME_MAX_BYTES && !name.chars().any(char::is_control)
134}
135
136pub fn load_cache_from(path: &Path) -> Result<McpDescriptorCache, DescriptorCacheError> {
148 let content = read_bounded_regular_file(path, CACHE_MAX_BYTES)?;
149 let raw: McpDescriptorCache = serde_json::from_str(&content).map_err(|err| {
150 DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
151 })?;
152 if raw.version != CACHE_FORMAT_VERSION {
153 return Err(DescriptorCacheError::Version(raw.version));
154 }
155 sanitize_cache(raw)
156}
157
158pub(crate) fn read_bounded_regular_file(
164 path: &Path,
165 max_bytes: u64,
166) -> Result<String, DescriptorCacheError> {
167 use std::io::Read;
168
169 let map_io =
170 |err: &std::io::Error| BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text;
171
172 #[cfg(unix)]
173 let file = {
174 use std::os::unix::fs::OpenOptionsExt;
175 std::fs::OpenOptions::new()
176 .read(true)
177 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
178 .open(path)
179 .map_err(|err| {
180 if err.raw_os_error() == Some(libc::ELOOP) {
181 DescriptorCacheError::NotRegularFile
182 } else if err.kind() == std::io::ErrorKind::NotFound {
183 DescriptorCacheError::NotFound
184 } else if err.raw_os_error() == Some(libc::EISDIR) {
185 DescriptorCacheError::NotRegularFile
186 } else {
187 DescriptorCacheError::Io(map_io(&err))
188 }
189 })?
190 };
191 #[cfg(not(unix))]
192 let file = {
193 let meta = std::fs::symlink_metadata(path).map_err(|err| {
196 if err.kind() == std::io::ErrorKind::NotFound {
197 DescriptorCacheError::NotFound
198 } else {
199 DescriptorCacheError::Io(map_io(&err))
200 }
201 })?;
202 if meta.file_type().is_symlink() {
203 return Err(DescriptorCacheError::NotRegularFile);
204 }
205 std::fs::File::open(path).map_err(|err| DescriptorCacheError::Io(map_io(&err)))?
206 };
207
208 let meta = file
210 .metadata()
211 .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
212 if !meta.file_type().is_file() {
213 return Err(DescriptorCacheError::NotRegularFile);
214 }
215 if meta.len() > max_bytes {
216 return Err(DescriptorCacheError::Oversize {
217 actual: meta.len(),
218 max: max_bytes,
219 });
220 }
221 let mut content = String::new();
222 file.take(max_bytes + 1)
223 .read_to_string(&mut content)
224 .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
225 if content.len() as u64 > max_bytes {
226 return Err(DescriptorCacheError::Oversize {
228 actual: content.len() as u64,
229 max: max_bytes,
230 });
231 }
232 Ok(content)
233}
234
235fn sanitize_cache(raw: McpDescriptorCache) -> Result<McpDescriptorCache, DescriptorCacheError> {
236 let mut sanitized = McpDescriptorCache::empty();
237 for (server, entry) in raw.servers {
238 if !valid_server_name(&server) {
239 tracing::warn!(server = %BoundedText::new(&server, SERVER_NAME_MAX_BYTES).text,
240 "Dropping descriptor cache entry with invalid server name");
241 continue;
242 }
243 let mut seen: HashSet<String> = HashSet::new();
244 let mut tools = Vec::new();
245 for descriptor in entry.tools.into_iter() {
246 if tools.len() >= SERVER_MAX_TOOLS {
247 break;
248 }
249 if !valid_tool_name(&descriptor.name) {
250 tracing::warn!(server = %server, "Dropping cached MCP descriptor with invalid name");
251 continue;
252 }
253 if !seen.insert(descriptor.name.clone()) {
254 tracing::warn!(server = %server, tool = %descriptor.name,
255 "Dropping duplicate cached MCP descriptor (keeping first)");
256 continue;
257 }
258 if !descriptor.input_schema.is_object() {
259 tracing::warn!(server = %server, tool = %descriptor.name,
260 "Dropping cached MCP descriptor with non-object schema");
261 continue;
262 }
263 let schema_len = serde_json::to_vec(&descriptor.input_schema)
264 .map(|bytes| bytes.len())
265 .unwrap_or(usize::MAX);
266 if schema_len > TOOL_SCHEMA_MAX_BYTES {
267 tracing::warn!(server = %server, tool = %descriptor.name,
268 "Dropping cached MCP descriptor with oversized schema");
269 continue;
270 }
271 tools.push(CachedToolDescriptor {
272 name: descriptor.name,
273 description: BoundedText::new(&descriptor.description, TOOL_DESCRIPTION_MAX_BYTES)
274 .text,
275 input_schema: descriptor.input_schema,
276 });
277 }
278 sanitized.servers.insert(
279 server,
280 CachedServerDescriptors {
281 fingerprint: entry.fingerprint,
282 tools,
283 },
284 );
285 }
286 Ok(sanitized)
287}
288
289pub fn default_cache_path() -> PathBuf {
295 crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
296}
297
298pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
300 load_cache_from(&default_cache_path())
301}
302
303pub fn store_cache_at(path: &Path, cache: &McpDescriptorCache) -> Result<(), DescriptorCacheError> {
310 use agent_core::core::private_fs::{ensure_private_dir, write_atomic_private, PrivateFsError};
311
312 let bytes = serde_json::to_vec_pretty(cache).map_err(|err| {
313 DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
314 })?;
315 let map_fs = |err: PrivateFsError| match err {
316 PrivateFsError::SymlinkRefused(_) => DescriptorCacheError::NotRegularFile,
317 PrivateFsError::Io(io) => {
318 DescriptorCacheError::Io(BoundedText::new(&io.to_string(), ERROR_ECHO_MAX_BYTES).text)
319 }
320 };
321 if let Some(parent) = path.parent() {
322 if !parent.as_os_str().is_empty() {
323 ensure_private_dir(parent).map_err(map_fs)?;
324 }
325 }
326 write_atomic_private(path, &bytes).map_err(map_fs)
327}
328
329pub struct DeferredMcpTool {
337 server_name: String,
338 runtime_name: String,
339 server_tool_name: String,
340 description: String,
341 input_schema: Value,
342 expected_fingerprint: String,
343 expected_digest: SchemaDigest,
344}
345
346impl DeferredMcpTool {
347 fn new(server: &str, descriptor: &CachedToolDescriptor, fingerprint: &str) -> Self {
348 Self {
349 server_name: server.to_string(),
350 runtime_name: format!("ext__{}__{}", server, descriptor.name),
353 server_tool_name: descriptor.name.clone(),
354 description: format!("[MCP:{}] {}", server, descriptor.description),
355 input_schema: descriptor.input_schema.clone(),
356 expected_fingerprint: fingerprint.to_string(),
357 expected_digest: SchemaDigest::of_schema(&descriptor.input_schema),
358 }
359 }
360
361 pub fn server_name(&self) -> &str {
362 &self.server_name
363 }
364
365 pub fn server_tool_name(&self) -> &str {
366 &self.server_tool_name
367 }
368
369 pub fn expected_fingerprint(&self) -> &str {
371 &self.expected_fingerprint
372 }
373
374 pub fn expected_digest(&self) -> &SchemaDigest {
378 &self.expected_digest
379 }
380}
381
382#[async_trait::async_trait]
383impl Tool for DeferredMcpTool {
384 fn origin(&self) -> ToolOrigin {
385 ToolOrigin::Mcp {
386 server_id: self.server_name.clone(),
387 server_tool_name: self.server_tool_name.clone(),
388 }
389 }
390
391 fn name(&self) -> &str {
392 &self.runtime_name
393 }
394
395 fn description(&self) -> &str {
396 &self.description
397 }
398
399 fn parameters(&self) -> Value {
400 self.input_schema.clone()
401 }
402
403 async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
404 let Some(leases) = ctx.capabilities.mcp_leases.clone() else {
408 return Err(crate::RuntimeError::Tool(format!(
409 "MCP tool '{}' (server '{}') is activation-deferred and no MCP session \
410 lease manager is available in this context; no process was started",
411 self.runtime_name, self.server_name
412 )));
413 };
414 leases
415 .call_exact(
416 &self.server_name,
417 &self.expected_fingerprint,
418 &self.server_tool_name,
419 &self.expected_digest,
420 params,
421 )
422 .await
423 .map_err(|err| {
424 use super::lease::McpLeaseError;
430 if matches!(
431 err,
432 McpLeaseError::ServerNotConfigured(_)
433 | McpLeaseError::FingerprintDrift(_)
434 | McpLeaseError::NameNotListed(_, _)
435 | McpLeaseError::SchemaMismatch(_, _)
436 ) {
437 if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
438 if activation.revoke_exact_mcp_grant(
439 leases.session(),
440 &self.server_name,
441 &self.server_tool_name,
442 ) {
443 tracing::debug!(
444 "revoked exact MCP activation after descriptor invalidation"
445 );
446 }
447 }
448 }
449 crate::RuntimeError::Tool(err.to_string())
450 })
451 }
452}
453
454pub fn dormant_tools_for_config(
460 config: &McpConfig,
461 cache: &McpDescriptorCache,
462) -> Vec<Arc<dyn Tool>> {
463 let mut servers: Vec<(&String, &McpServerConfig)> = config.mcp_servers.iter().collect();
464 servers.sort_by(|a, b| a.0.cmp(b.0));
465
466 let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
467 for (server, server_config) in servers {
468 let Some(entry) = cache.servers.get(server) else {
469 tracing::debug!(server = %server,
470 "MCP server has no cached descriptors; it stays undiscoverable (no process is started to ask)");
471 continue;
472 };
473 let current = server_config_fingerprint(server_config);
474 if entry.fingerprint != current {
475 tracing::warn!(server = %server,
476 "MCP descriptor cache fingerprint does not match the current config; cached descriptors are invalid");
477 continue;
478 }
479 for descriptor in &entry.tools {
480 tools.push(Arc::new(DeferredMcpTool::new(
481 server,
482 descriptor,
483 &entry.fingerprint,
484 )));
485 }
486 }
487 tools
488}
489
490#[cfg(all(test, unix))]
491mod tests {
492 use super::*;
493 use serial_test::serial;
494 use std::os::unix::fs::PermissionsExt;
495
496 struct UmaskGuard {
498 old: libc::mode_t,
499 }
500 impl UmaskGuard {
501 fn set(mask: libc::mode_t) -> Self {
502 Self {
503 old: unsafe { libc::umask(mask) },
504 }
505 }
506 }
507 impl Drop for UmaskGuard {
508 fn drop(&mut self) {
509 unsafe {
510 libc::umask(self.old);
511 }
512 }
513 }
514
515 fn mode_of(path: &Path) -> u32 {
516 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
517 }
518
519 #[test]
523 #[serial(umask)]
524 fn store_is_private_even_under_permissive_umask() {
525 let base = std::env::temp_dir().join(format!("synaps-mcp-umask-{}", std::process::id()));
526 let _ = std::fs::remove_dir_all(&base);
527 let _guard = UmaskGuard::set(0);
528
529 let path = base.join("nested").join(DESCRIPTOR_CACHE_FILE);
530 store_cache_at(&path, &McpDescriptorCache::empty()).unwrap();
531
532 assert_eq!(mode_of(&path), 0o600, "cache file must be private");
533 assert_eq!(
534 mode_of(&base.join("nested")),
535 0o700,
536 "created cache dir must be private"
537 );
538 let _ = std::fs::remove_dir_all(&base);
539 }
540}