1use std::fmt;
10use std::sync::Arc;
11
12use anyhow::{anyhow, Result};
13use async_trait::async_trait;
14use thiserror::Error;
15
16use super::client::McpClient;
17use super::protocol::McpTool;
18use super::result::project_tool_result;
19use super::tools::annotation_requires_confirmation;
20use crate::tools::{Tool, ToolContext, ToolOutput};
21
22pub const MAX_MCP_BINDING_TOOLS: usize = 1_024;
23pub const MAX_MCP_BINDING_DEFINITION_BYTES: usize = 16 * 1024 * 1024;
24const MAX_MCP_NAME_BYTES: usize = 256;
25const MAX_MCP_FULL_TOOL_NAME_BYTES: usize = 768;
26
27#[derive(Clone, Debug, Eq, Error, PartialEq)]
29pub enum McpBindingError {
30 #[error("MCP {field} is empty, padded, contains control characters, or exceeds its bound")]
31 InvalidName { field: &'static str },
32 #[error("MCP binding server name does not match its exact client identity")]
33 ClientNameMismatch,
34 #[error("MCP binding exceeds the {field} bound of {max}")]
35 BoundExceeded { field: &'static str, max: usize },
36 #[error("MCP binding repeats tool name '{name}'")]
37 DuplicateToolName { name: String },
38 #[error("MCP binding tool catalog is not serializable")]
39 InvalidToolCatalog,
40 #[error("MCP binding client is not initialized and connected")]
41 ClientNotReady,
42}
43
44pub struct McpBinding {
52 server_name: Box<str>,
53 client: Arc<McpClient>,
54 tools: Arc<[McpTool]>,
55}
56
57impl McpBinding {
58 pub fn new(
59 server_name: impl Into<String>,
60 client: Arc<McpClient>,
61 tools: impl IntoIterator<Item = McpTool>,
62 ) -> std::result::Result<Self, McpBindingError> {
63 let server_name = server_name.into();
64 validate_name("server name", &server_name)?;
65 if client.name != server_name {
66 return Err(McpBindingError::ClientNameMismatch);
67 }
68 if !client.is_ready() {
69 return Err(McpBindingError::ClientNotReady);
70 }
71
72 let mut tools = tools.into_iter().collect::<Vec<_>>();
73 if tools.len() > MAX_MCP_BINDING_TOOLS {
74 return Err(McpBindingError::BoundExceeded {
75 field: "tool count",
76 max: MAX_MCP_BINDING_TOOLS,
77 });
78 }
79 tools.sort_by(|left, right| left.name.cmp(&right.name));
80
81 let mut definition_bytes = 0_usize;
82 let mut previous_name: Option<&str> = None;
83 for tool in &tools {
84 validate_name("tool name", &tool.name)?;
85 let full_name_len = "mcp__"
86 .len()
87 .saturating_add(server_name.len())
88 .saturating_add("__".len())
89 .saturating_add(tool.name.len());
90 if full_name_len > MAX_MCP_FULL_TOOL_NAME_BYTES {
91 return Err(McpBindingError::BoundExceeded {
92 field: "fully qualified tool name bytes",
93 max: MAX_MCP_FULL_TOOL_NAME_BYTES,
94 });
95 }
96 if previous_name == Some(tool.name.as_str()) {
97 return Err(McpBindingError::DuplicateToolName {
98 name: tool.name.clone(),
99 });
100 }
101 previous_name = Some(&tool.name);
102 let encoded =
103 serde_json::to_vec(tool).map_err(|_| McpBindingError::InvalidToolCatalog)?;
104 definition_bytes = definition_bytes.saturating_add(encoded.len());
105 if definition_bytes > MAX_MCP_BINDING_DEFINITION_BYTES {
106 return Err(McpBindingError::BoundExceeded {
107 field: "tool definition bytes",
108 max: MAX_MCP_BINDING_DEFINITION_BYTES,
109 });
110 }
111 }
112
113 Ok(Self {
114 server_name: server_name.into_boxed_str(),
115 client,
116 tools: tools.into(),
117 })
118 }
119
120 pub fn server_name(&self) -> &str {
121 &self.server_name
122 }
123
124 pub fn tools(&self) -> &[McpTool] {
125 &self.tools
126 }
127
128 pub fn is_ready(&self) -> bool {
129 self.client.is_ready()
130 }
131
132 pub fn validate_run_scope(&self) -> std::result::Result<(), McpBindingError> {
133 if self.is_ready() {
134 Ok(())
135 } else {
136 Err(McpBindingError::ClientNotReady)
137 }
138 }
139
140 pub fn contains_public_tool_name(&self, full_name: &str) -> bool {
141 self.tools
142 .iter()
143 .any(|tool| full_name == format!("mcp__{}__{}", self.server_name, tool.name))
144 }
145
146 pub(crate) fn projected_tools(self: &Arc<Self>) -> Vec<Arc<dyn Tool>> {
147 self.tools
148 .iter()
149 .enumerate()
150 .map(|(tool_index, tool)| {
151 Arc::new(ProjectedMcpTool {
152 full_name: format!("mcp__{}__{}", self.server_name, tool.name).into_boxed_str(),
153 tool_index,
154 binding: Arc::clone(self),
155 }) as Arc<dyn Tool>
156 })
157 .collect()
158 }
159
160 async fn call_tool(
161 &self,
162 tool_name: &str,
163 arguments: Option<serde_json::Value>,
164 ) -> Result<super::protocol::CallToolResult> {
165 if self
166 .tools
167 .binary_search_by(|tool| tool.name.as_str().cmp(tool_name))
168 .is_err()
169 {
170 return Err(anyhow!(
171 "MCP tool '{}' is not present in the frozen server binding",
172 tool_name
173 ));
174 }
175 self.client.call_tool(tool_name, arguments).await
176 }
177}
178
179impl fmt::Debug for McpBinding {
180 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181 formatter
182 .debug_struct("McpBinding")
183 .field("server_name", &self.server_name)
184 .field("tool_count", &self.tools.len())
185 .field("ready", &self.is_ready())
186 .finish_non_exhaustive()
187 }
188}
189
190struct ProjectedMcpTool {
191 full_name: Box<str>,
192 tool_index: usize,
193 binding: Arc<McpBinding>,
194}
195
196impl ProjectedMcpTool {
197 fn tool(&self) -> &McpTool {
198 &self.binding.tools[self.tool_index]
199 }
200}
201
202#[async_trait]
203impl Tool for ProjectedMcpTool {
204 fn name(&self) -> &str {
205 &self.full_name
206 }
207
208 fn description(&self) -> &str {
209 self.tool().description.as_deref().unwrap_or("MCP tool")
210 }
211
212 fn parameters(&self) -> serde_json::Value {
213 self.tool().input_schema.clone()
214 }
215
216 fn requires_confirmation(&self, _args: &serde_json::Value) -> bool {
217 annotation_requires_confirmation(self.tool())
218 }
219
220 async fn execute(&self, args: &serde_json::Value, context: &ToolContext) -> Result<ToolOutput> {
221 if context.is_cancelled() {
222 return Ok(ToolOutput::error(format!(
223 "MCP tool '{}' cancelled by caller",
224 self.full_name
225 )));
226 }
227
228 let cancellation = context.cancellation_token();
229 let call = self
230 .binding
231 .call_tool(&self.tool().name, Some(args.clone()));
232 let result = tokio::select! {
233 _ = cancellation.cancelled() => {
234 return Ok(ToolOutput::error(format!(
235 "MCP tool '{}' cancelled by caller",
236 self.full_name
237 )));
238 }
239 result = call => result,
240 };
241 match result {
242 Ok(result) => project_tool_result(&self.full_name, &result, context).await,
243 Err(error) => Ok(ToolOutput::error(format!("MCP tool error: {error}"))),
244 }
245 }
246}
247
248fn validate_name(field: &'static str, value: &str) -> std::result::Result<(), McpBindingError> {
249 if value.is_empty()
250 || value.trim() != value
251 || value.len() > MAX_MCP_NAME_BYTES
252 || value.chars().any(char::is_control)
253 {
254 return Err(McpBindingError::InvalidName { field });
255 }
256 Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261 use std::path::PathBuf;
262
263 use super::*;
264 use crate::mcp::test_support::{mcp_tool, ready_binding, RecordingMcpTransport};
265 use crate::mcp::transport::McpTransport;
266 use crate::mcp::McpProjectionAdapter;
267 use async_trait::async_trait;
268
269 #[tokio::test]
270 async fn binding_rejects_clients_that_are_not_ready_or_have_another_identity() {
271 let transport = RecordingMcpTransport::new("uninitialized", Vec::new());
272 let client = Arc::new(McpClient::new(
273 "catalog".to_string(),
274 Arc::clone(&transport) as Arc<dyn McpTransport>,
275 ));
276 assert_eq!(
277 McpBinding::new("catalog", Arc::clone(&client), Vec::new()).unwrap_err(),
278 McpBindingError::ClientNotReady
279 );
280
281 client.initialize().await.unwrap();
282 assert_eq!(
283 McpBinding::new("another", Arc::clone(&client), Vec::new()).unwrap_err(),
284 McpBindingError::ClientNameMismatch
285 );
286
287 transport.disconnect();
288 assert_eq!(
289 McpBinding::new("catalog", client, Vec::new()).unwrap_err(),
290 McpBindingError::ClientNotReady
291 );
292 }
293
294 #[tokio::test]
295 async fn binding_canonicalizes_tools_and_rejects_duplicate_or_oversized_catalogs() {
296 let (_, _transport, client) = ready_binding("catalog", "one", Vec::new()).await;
297 let binding = McpBinding::new(
298 "catalog",
299 Arc::clone(&client),
300 [mcp_tool("zeta", "last"), mcp_tool("alpha", "first")],
301 )
302 .unwrap();
303 assert_eq!(
304 binding
305 .tools()
306 .iter()
307 .map(|tool| tool.name.as_str())
308 .collect::<Vec<_>>(),
309 ["alpha", "zeta"]
310 );
311
312 assert_eq!(
313 McpBinding::new(
314 "catalog",
315 Arc::clone(&client),
316 [mcp_tool("same", "one"), mcp_tool("same", "two")],
317 )
318 .unwrap_err(),
319 McpBindingError::DuplicateToolName {
320 name: "same".to_string()
321 }
322 );
323
324 let too_many = (0..=MAX_MCP_BINDING_TOOLS)
325 .map(|index| mcp_tool(&format!("tool-{index:04}"), "bounded"))
326 .collect::<Vec<_>>();
327 assert_eq!(
328 McpBinding::new("catalog", Arc::clone(&client), too_many).unwrap_err(),
329 McpBindingError::BoundExceeded {
330 field: "tool count",
331 max: MAX_MCP_BINDING_TOOLS,
332 }
333 );
334
335 let oversized = mcp_tool("oversized", &"x".repeat(MAX_MCP_BINDING_DEFINITION_BYTES));
336 assert_eq!(
337 McpBinding::new("catalog", client, [oversized]).unwrap_err(),
338 McpBindingError::BoundExceeded {
339 field: "tool definition bytes",
340 max: MAX_MCP_BINDING_DEFINITION_BYTES,
341 }
342 );
343 }
344
345 #[tokio::test]
346 async fn projected_wrapper_calls_the_raw_tool_on_the_exact_client() {
347 let (binding, transport, _) = ready_binding(
348 "catalog",
349 "generation-one",
350 vec![mcp_tool("lookup", "generation-one")],
351 )
352 .await;
353 let wrappers = binding.projected_tools();
354 assert_eq!(wrappers.len(), 1);
355 assert_eq!(wrappers[0].name(), "mcp__catalog__lookup");
356 assert_eq!(wrappers[0].description(), "generation-one");
357
358 let arguments = serde_json::json!({"generation": "one"});
359 let output = wrappers[0]
360 .execute(&arguments, &ToolContext::new(PathBuf::from("/tmp")))
361 .await
362 .unwrap();
363 assert!(output.success);
364 assert_eq!(output.content, "generation-one");
365 assert_eq!(
366 transport.calls(),
367 [crate::mcp::test_support::RecordedMcpCall {
368 name: "lookup".to_string(),
369 arguments: Some(arguments),
370 }]
371 );
372 }
373
374 #[tokio::test]
375 async fn projected_wrapper_returns_promptly_when_caller_cancels() {
376 let (binding, transport, _) = ready_binding(
377 "catalog",
378 "generation-one",
379 vec![mcp_tool("lookup", "generation-one")],
380 )
381 .await;
382 let wrappers = binding.projected_tools();
383 let cancel = tokio_util::sync::CancellationToken::new();
384 cancel.cancel();
385 let output = wrappers[0]
386 .execute(
387 &serde_json::json!({}),
388 &ToolContext::new(PathBuf::from("/tmp")).with_cancellation(cancel),
389 )
390 .await
391 .unwrap();
392 assert!(!output.success);
393 assert!(output.content.contains("cancelled by caller"));
394 assert!(
395 transport.calls().is_empty(),
396 "cancelled calls must not reach the MCP client"
397 );
398 }
399
400 #[test]
401 fn projected_mcp_types_are_send_and_sync() {
402 fn assert_send_sync<T: Send + Sync>() {}
403
404 assert_send_sync::<McpBinding>();
405 assert_send_sync::<McpProjectionAdapter>();
406 }
407
408 #[tokio::test]
409 async fn denied_governed_mcp_call_does_not_reach_the_server() {
410 let (binding, transport, _) = ready_binding(
411 "catalog",
412 "generation-one",
413 vec![mcp_tool("lookup", "lookup")],
414 )
415 .await;
416 let wrappers = binding.projected_tools();
417 let tool_name = wrappers[0].name().to_string();
418 assert_eq!(tool_name, "mcp__catalog__lookup");
419
420 let directory = tempfile::tempdir().unwrap();
421 let executor = Arc::new(crate::tools::ToolExecutor::new(
422 directory.path().to_string_lossy().to_string(),
423 ));
424 executor.register_dynamic_tool(Arc::clone(&wrappers[0]));
425 let session_id = "mcp-deny";
426 let context = ToolContext::new(directory.path().to_path_buf()).with_session_id(session_id);
427 let gate = Arc::new(DenyThenAllow {
428 allow: std::sync::atomic::AtomicBool::new(false),
429 });
430 let agent = crate::agent::AgentLoop::new(
431 Arc::new(IdleModel),
432 executor,
433 context.clone(),
434 crate::agent::AgentConfig {
435 permission_checker: Some(
436 Arc::clone(&gate) as Arc<dyn crate::permissions::PermissionChecker>
437 ),
438 ..crate::agent::AgentConfig::default()
439 },
440 );
441
442 let denied = agent
443 .invoke_host_tool(
444 crate::tools::ToolInvocation::host_governed(
445 "mcp-deny-1",
446 tool_name.clone(),
447 serde_json::json!({"generation": "secret"}),
448 ),
449 session_id,
450 &None,
451 &tokio_util::sync::CancellationToken::new(),
452 &context,
453 )
454 .await;
455 assert_ne!(denied.exit_code, 0, "{}", denied.output);
456 assert!(
457 denied.output.contains("Permission denied"),
458 "{}",
459 denied.output
460 );
461 assert!(
462 transport.calls().is_empty(),
463 "denied MCP call mutated the server: {:?}",
464 transport.calls()
465 );
466
467 gate.allow.store(true, std::sync::atomic::Ordering::SeqCst);
468 let allowed = agent
469 .invoke_host_tool(
470 crate::tools::ToolInvocation::host_governed(
471 "mcp-allow-1",
472 tool_name,
473 serde_json::json!({"generation": "visible"}),
474 ),
475 session_id,
476 &None,
477 &tokio_util::sync::CancellationToken::new(),
478 &context,
479 )
480 .await;
481 assert_eq!(allowed.exit_code, 0, "{}", allowed.output);
482 assert_eq!(transport.calls().len(), 1);
483 assert_eq!(transport.calls()[0].name, "lookup");
484 }
485
486 struct DenyThenAllow {
487 allow: std::sync::atomic::AtomicBool,
488 }
489
490 impl crate::permissions::PermissionChecker for DenyThenAllow {
491 fn check(
492 &self,
493 tool_name: &str,
494 _args: &serde_json::Value,
495 ) -> crate::permissions::PermissionDecision {
496 assert_eq!(tool_name, "mcp__catalog__lookup");
497 if self.allow.load(std::sync::atomic::Ordering::SeqCst) {
498 crate::permissions::PermissionDecision::Allow
499 } else {
500 crate::permissions::PermissionDecision::Deny
501 }
502 }
503 }
504
505 struct IdleModel;
506
507 #[async_trait]
508 impl crate::llm::LlmClient for IdleModel {
509 async fn complete(
510 &self,
511 _messages: &[crate::llm::Message],
512 _system: Option<&str>,
513 _tools: &[crate::llm::ToolDefinition],
514 ) -> anyhow::Result<crate::llm::LlmResponse> {
515 anyhow::bail!("MCP policy test must not call the model")
516 }
517
518 async fn complete_streaming(
519 &self,
520 _messages: &[crate::llm::Message],
521 _system: Option<&str>,
522 _tools: &[crate::llm::ToolDefinition],
523 _cancel_token: tokio_util::sync::CancellationToken,
524 ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
525 anyhow::bail!("MCP policy test must not stream the model")
526 }
527 }
528}