1#![deny(missing_docs)]
8#![deny(rustdoc::broken_intra_doc_links)]
9
10pub mod testing;
11mod transport;
12
13pub use transport::{McpCredential, McpCredentialProvider, StreamableHttpTransport};
14
15use af_context::{RequestId, RunId, SessionId, SubjectId, TenantId, ToolCallId};
16use std::collections::{BTreeMap, BTreeSet};
17use std::net::IpAddr;
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20
21use af_agent::{
22 validate_json_schema, AgentPlugin, AgentRegistrar, PluginError, PluginLease, PluginManifest,
23 PluginMountContext, PluginPermission, Tool, ToolExecutionOwner, ToolMeta, ToolRiskClass,
24};
25use async_trait::async_trait;
26use reqwest::Url;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct McpEndpoint {
33 pub id: String,
35 pub url: String,
37 pub namespace: String,
39 pub allowed_hosts: BTreeSet<String>,
41 pub allowed_tools: BTreeSet<String>,
43 pub credential_ref: Option<String>,
45 pub timeout_ms: u64,
47 pub failure_threshold: u32,
49 pub recovery_ms: u64,
51}
52
53impl McpEndpoint {
54 pub fn validate(&self) -> Result<Url, McpError> {
56 let url = Url::parse(&self.url).map_err(|_| McpError::Rejected("invalid URL".into()))?;
57 if url.scheme() != "https"
58 || !url.username().is_empty()
59 || url.password().is_some()
60 || url.fragment().is_some()
61 {
62 return Err(McpError::Rejected(
63 "MCP requires an HTTPS URL without credentials or fragments".into(),
64 ));
65 }
66 let host = url
67 .host_str()
68 .ok_or_else(|| McpError::Rejected("missing host".into()))?;
69 if host.parse::<IpAddr>().is_ok()
70 || host.eq_ignore_ascii_case("localhost")
71 || !self.allowed_hosts.contains(host)
72 {
73 return Err(McpError::Rejected(
74 "host is not an allowlisted DNS name".into(),
75 ));
76 }
77 if self.id.trim().is_empty()
78 || self.namespace.trim().is_empty()
79 || self.allowed_tools.is_empty()
80 || self.timeout_ms == 0
81 || self.failure_threshold == 0
82 {
83 return Err(McpError::Rejected(
84 "id, namespace, tool allowlist, timeout and failure threshold are required".into(),
85 ));
86 }
87 Ok(url)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct McpCallContext {
94 pub tenant_id: TenantId,
96 pub subject_id: SubjectId,
98 pub session_id: SessionId,
100 pub run_id: RunId,
102 pub call_id: ToolCallId,
104 pub source_event_seq: u64,
106 pub request_id: RequestId,
108}
109
110#[derive(Clone)]
112pub struct McpCallControl {
113 pub cancellation: af_agent::CancellationToken,
115 pub deadline: Instant,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct McpTool {
123 pub name: String,
125 pub description: String,
127 pub input_schema: Value,
129 #[serde(default)]
131 pub output_schema: Value,
132 #[serde(default)]
134 pub risk_class: ToolRiskClass,
135 #[serde(default)]
137 pub execution_owner: ToolExecutionOwner,
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct McpToolResult {
143 pub content: Value,
145 pub is_error: bool,
147}
148
149#[derive(Debug, Clone, PartialEq)]
151pub enum McpCallClaim {
152 Execute,
154 Completed(McpToolResult),
156 OutcomeUnknown,
158}
159
160#[async_trait]
162pub trait McpTransport: Send + Sync {
163 async fn list_tools(&self, endpoint: &McpEndpoint) -> Result<Vec<McpTool>, McpError>;
166 async fn call_tool(
168 &self,
169 endpoint: &McpEndpoint,
170 context: &McpCallContext,
171 name: &str,
172 arguments: Value,
173 ) -> Result<McpToolResult, McpError>;
174}
175
176#[async_trait]
178pub trait McpGuard: Send + Sync {
179 async fn authorize(
181 &self,
182 context: &McpCallContext,
183 endpoint: &str,
184 tool: &str,
185 arguments: &Value,
186 ) -> Result<(), McpError>;
187}
188
189#[async_trait]
191pub trait McpAudit: Send + Sync {
192 async fn claim(
194 &self,
195 context: &McpCallContext,
196 endpoint: &str,
197 tool: &str,
198 arguments: &Value,
199 ) -> Result<McpCallClaim, McpError>;
200 async fn complete(
202 &self,
203 context: &McpCallContext,
204 endpoint: &str,
205 tool: &str,
206 result: &McpToolResult,
207 ) -> Result<(), McpError>;
208 async fn outcome_unknown(
210 &self,
211 context: &McpCallContext,
212 endpoint: &str,
213 tool: &str,
214 error: &str,
215 ) -> Result<(), McpError>;
216}
217
218#[derive(Default)]
219struct Circuit {
220 failures: u32,
221 opened_at: Option<Instant>,
222}
223
224pub struct McpClient<T, G, A> {
226 endpoint: McpEndpoint,
227 transport: T,
228 guard: G,
229 audit: A,
230 circuit: Mutex<Circuit>,
231}
232
233pub struct McpClientPlugin<T, G, A> {
235 manifest: PluginManifest,
236 client: Arc<McpClient<T, G, A>>,
237}
238
239impl<T, G, A> McpClientPlugin<T, G, A> {
240 pub fn new(client: McpClient<T, G, A>) -> Self {
242 let id = format!("agentfactory.mcp.{}", client.endpoint.id);
243 Self {
244 manifest: PluginManifest {
245 id,
246 version: "0.3.0".into(),
247 dependencies: BTreeMap::new(),
248 config_schema: serde_json::json!({"type":"object"}),
249 permissions: BTreeSet::from([PluginPermission::Tool]),
250 },
251 client: Arc::new(client),
252 }
253 }
254}
255
256#[async_trait]
257impl<T, G, A> AgentPlugin for McpClientPlugin<T, G, A>
258where
259 T: McpTransport + 'static,
260 G: McpGuard + 'static,
261 A: McpAudit + 'static,
262{
263 fn manifest(&self) -> &PluginManifest {
264 &self.manifest
265 }
266
267 fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
268 Box::new(Lease)
269 }
270
271 async fn activate(
272 &self,
273 _scope: &PluginMountContext,
274 _: &Value,
275 registrar: &mut AgentRegistrar<'_>,
276 ) -> Result<(), PluginError> {
277 let tools = self
278 .client
279 .list_tools()
280 .await
281 .map_err(|error| PluginError::Mount(format!("{}: {error}", self.manifest.id)))?;
282 for tool in tools {
283 registrar.tool(Arc::new(RemoteTool {
284 client: Arc::clone(&self.client),
285 tool,
286 }))?;
287 }
288 Ok(())
289 }
290}
291
292struct RemoteTool<T, G, A> {
293 client: Arc<McpClient<T, G, A>>,
294 tool: McpTool,
295}
296
297#[async_trait]
298impl<T, G, A> Tool for RemoteTool<T, G, A>
299where
300 T: McpTransport + 'static,
301 G: McpGuard + 'static,
302 A: McpAudit + 'static,
303{
304 fn name(&self) -> &str {
305 &self.tool.name
306 }
307 fn description(&self) -> &str {
308 &self.tool.description
309 }
310 fn parameters(&self) -> Value {
311 self.tool.input_schema.clone()
312 }
313 fn output_schema(&self) -> Value {
314 if self.tool.output_schema.is_null() {
315 serde_json::json!({})
316 } else {
317 self.tool.output_schema.clone()
318 }
319 }
320 fn meta(&self) -> ToolMeta {
321 ToolMeta {
322 risk_class: self.tool.risk_class,
323 execution_owner: self.tool.execution_owner,
324 ..Default::default()
325 }
326 }
327 async fn call(&self, arguments: Value) -> Result<Value, String> {
328 let _ = arguments;
329 Err("durable tool execution context is required".into())
330 }
331 async fn call_with_context(
332 &self,
333 execution: &af_agent::ToolExecutionContext,
334 arguments: Value,
335 ) -> Result<Value, String> {
336 let context = McpCallContext {
337 tenant_id: execution.request.tenant_id.clone(),
338 subject_id: execution.request.subject_id.clone(),
339 session_id: execution.session_id.clone(),
340 run_id: execution.run_id.clone(),
341 call_id: execution.call_id.clone(),
342 source_event_seq: execution.source_event_seq,
343 request_id: execution.request.request_id.clone(),
344 };
345 let control = McpCallControl {
346 cancellation: execution.cancellation.clone(),
347 deadline: execution.deadline,
348 };
349 let result = self
350 .client
351 .call_controlled(&context, &self.tool, arguments, &control)
352 .await
353 .map_err(|error| error.to_string())?;
354 if result.is_error {
355 Err(format!("remote MCP tool failed: {}", result.content))
356 } else {
357 Ok(result.content)
358 }
359 }
360}
361
362struct Lease;
363#[async_trait]
364impl PluginLease for Lease {
365 async fn unmount(&mut self) -> Result<(), PluginError> {
366 Ok(())
367 }
368}
369
370impl<T: McpTransport, G: McpGuard, A: McpAudit> McpClient<T, G, A> {
371 pub fn new(endpoint: McpEndpoint, transport: T, guard: G, audit: A) -> Result<Self, McpError> {
373 endpoint.validate()?;
374 Ok(Self {
375 endpoint,
376 transport,
377 guard,
378 audit,
379 circuit: Mutex::new(Circuit::default()),
380 })
381 }
382
383 pub async fn list_tools(&self) -> Result<Vec<McpTool>, McpError> {
385 let control = self.default_control();
386 self.list_tools_controlled(&control).await
387 }
388
389 pub async fn list_tools_controlled(
391 &self,
392 control: &McpCallControl,
393 ) -> Result<Vec<McpTool>, McpError> {
394 self.ensure_closed()?;
395 let prefix = format!("{}.", self.endpoint.namespace);
396 let mut tools = self
397 .timed(control, self.transport.list_tools(&self.endpoint))
398 .await?;
399 let names = tools
400 .iter()
401 .map(|tool| tool.name.as_str())
402 .collect::<BTreeSet<_>>();
403 if tools.iter().any(|tool| {
404 tool.name.contains('.')
405 || tool.name.trim().is_empty()
406 || !tool.input_schema.is_object()
407 || (!tool.output_schema.is_null() && !tool.output_schema.is_object())
408 }) || names.len() != tools.len()
409 || !self
410 .endpoint
411 .allowed_tools
412 .iter()
413 .all(|name| names.contains(name.as_str()))
414 {
415 return Err(McpError::Rejected("remote tool catalog is invalid".into()));
416 }
417 tools.retain(|tool| self.endpoint.allowed_tools.contains(&tool.name));
418 for tool in &mut tools {
419 tool.name = format!("{prefix}{}", tool.name);
420 }
421 self.success();
422 Ok(tools)
423 }
424
425 pub async fn call(
427 &self,
428 context: &McpCallContext,
429 tool: &McpTool,
430 arguments: Value,
431 ) -> Result<McpToolResult, McpError> {
432 let control = self.default_control();
433 self.call_controlled(context, tool, arguments, &control)
434 .await
435 }
436
437 pub async fn call_controlled(
439 &self,
440 context: &McpCallContext,
441 tool: &McpTool,
442 arguments: Value,
443 control: &McpCallControl,
444 ) -> Result<McpToolResult, McpError> {
445 if control.cancellation.is_cancelled() {
446 return Err(McpError::Cancelled);
447 }
448 if control.deadline <= Instant::now() {
449 return Err(McpError::Timeout);
450 }
451 if context.tenant_id.trim().is_empty()
452 || context.subject_id.trim().is_empty()
453 || context.session_id.trim().is_empty()
454 || context.run_id.trim().is_empty()
455 || context.call_id.trim().is_empty()
456 || context.source_event_seq == 0
457 || context.request_id.trim().is_empty()
458 {
459 return Err(McpError::Rejected(
460 "authenticated tenant context is required".into(),
461 ));
462 }
463 self.ensure_closed()?;
464 let name = tool
465 .name
466 .strip_prefix(&format!("{}.", self.endpoint.namespace))
467 .ok_or_else(|| McpError::Rejected("tool is outside endpoint namespace".into()))?;
468 validate_json_schema(&tool.input_schema, &arguments).map_err(McpError::Rejected)?;
469 self.guard
470 .authorize(context, &self.endpoint.id, name, &arguments)
471 .await?;
472 match self
473 .audit
474 .claim(context, &self.endpoint.id, name, &arguments)
475 .await?
476 {
477 McpCallClaim::Completed(result) => {
478 validate_result(tool, &result)?;
479 return Ok(result);
480 }
481 McpCallClaim::OutcomeUnknown => return Err(McpError::OutcomeUnknown),
482 McpCallClaim::Execute => {}
483 }
484 let result = match self
485 .timed(
486 control,
487 self.transport
488 .call_tool(&self.endpoint, context, name, arguments),
489 )
490 .await
491 {
492 Ok(result) => result,
493 Err(error) => {
494 let _ = self
495 .audit
496 .outcome_unknown(context, &self.endpoint.id, name, &error.to_string())
497 .await;
498 return if error == McpError::Cancelled {
499 Err(error)
500 } else {
501 Err(McpError::OutcomeUnknown)
502 };
503 }
504 };
505 self.audit
506 .complete(context, &self.endpoint.id, name, &result)
507 .await
508 .map_err(|_| McpError::OutcomeUnknown)?;
509 self.success();
510 validate_result(tool, &result)?;
511 Ok(result)
512 }
513
514 async fn timed<R>(
515 &self,
516 control: &McpCallControl,
517 future: impl std::future::Future<Output = Result<R, McpError>>,
518 ) -> Result<R, McpError> {
519 let endpoint_timeout = Duration::from_millis(self.endpoint.timeout_ms);
520 let caller_timeout = control.deadline.saturating_duration_since(Instant::now());
521 let timeout = endpoint_timeout.min(caller_timeout);
522 let result = tokio::select! {
523 biased;
524 _ = control.cancellation.cancelled() => return Err(McpError::Cancelled),
525 result = tokio::time::timeout(timeout, future) => result,
526 };
527 match result {
528 Ok(Ok(value)) => Ok(value),
529 Ok(Err(error)) => {
530 self.failure();
531 Err(error)
532 }
533 Err(_) => {
534 if endpoint_timeout <= caller_timeout {
535 self.failure();
536 }
537 Err(McpError::Timeout)
538 }
539 }
540 }
541
542 fn default_control(&self) -> McpCallControl {
543 McpCallControl {
544 cancellation: af_agent::CancellationToken::default(),
545 deadline: Instant::now() + Duration::from_millis(self.endpoint.timeout_ms),
546 }
547 }
548
549 fn ensure_closed(&self) -> Result<(), McpError> {
550 let circuit = self
551 .circuit
552 .lock()
553 .unwrap_or_else(std::sync::PoisonError::into_inner);
554 if circuit.failures < self.endpoint.failure_threshold {
555 return Ok(());
556 }
557 if circuit.opened_at.is_some_and(|opened| {
558 opened.elapsed() >= Duration::from_millis(self.endpoint.recovery_ms)
559 }) {
560 return Ok(());
561 }
562 Err(McpError::CircuitOpen)
563 }
564 fn failure(&self) {
565 let mut circuit = self
566 .circuit
567 .lock()
568 .unwrap_or_else(std::sync::PoisonError::into_inner);
569 circuit.failures += 1;
570 if circuit.failures >= self.endpoint.failure_threshold {
571 circuit.opened_at = Some(Instant::now());
572 }
573 }
574 fn success(&self) {
575 *self
576 .circuit
577 .lock()
578 .unwrap_or_else(std::sync::PoisonError::into_inner) = Circuit::default();
579 }
580}
581
582fn validate_result(tool: &McpTool, result: &McpToolResult) -> Result<(), McpError> {
583 if result.is_error || tool.output_schema.is_null() {
584 return Ok(());
585 }
586 validate_json_schema(&tool.output_schema, &result.content)
587 .map_err(|error| McpError::Rejected(format!("remote tool output is invalid: {error}")))
588}
589
590#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
592pub enum McpError {
593 #[error("MCP operation cancelled")]
595 Cancelled,
596 #[error("MCP request rejected: {0}")]
598 Rejected(String),
599 #[error("MCP dependency unavailable: {0}")]
601 Unavailable(String),
602 #[error("MCP request timed out")]
604 Timeout,
605 #[error("MCP circuit is open")]
607 CircuitOpen,
608 #[error("MCP tool outcome is unknown; verify remote state before retrying")]
610 OutcomeUnknown,
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 struct Transport;
618 #[async_trait]
619 impl McpTransport for Transport {
620 async fn list_tools(&self, _: &McpEndpoint) -> Result<Vec<McpTool>, McpError> {
621 Ok(vec![McpTool {
622 name: "echo".into(),
623 description: String::new(),
624 input_schema: serde_json::json!({"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}),
625 output_schema: serde_json::json!({"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}),
626 risk_class: ToolRiskClass::Read,
627 execution_owner: ToolExecutionOwner::ProductHost,
628 }])
629 }
630 async fn call_tool(
631 &self,
632 _: &McpEndpoint,
633 _: &McpCallContext,
634 _: &str,
635 arguments: Value,
636 ) -> Result<McpToolResult, McpError> {
637 Ok(McpToolResult {
638 content: arguments,
639 is_error: false,
640 })
641 }
642 }
643 struct Allow;
644 #[async_trait]
645 impl McpGuard for Allow {
646 async fn authorize(
647 &self,
648 _: &McpCallContext,
649 _: &str,
650 _: &str,
651 _: &Value,
652 ) -> Result<(), McpError> {
653 Ok(())
654 }
655 }
656 #[async_trait]
657 impl McpAudit for Allow {
658 async fn claim(
659 &self,
660 _: &McpCallContext,
661 _: &str,
662 _: &str,
663 _: &Value,
664 ) -> Result<McpCallClaim, McpError> {
665 Ok(McpCallClaim::Execute)
666 }
667 async fn complete(
668 &self,
669 _: &McpCallContext,
670 _: &str,
671 _: &str,
672 _: &McpToolResult,
673 ) -> Result<(), McpError> {
674 Ok(())
675 }
676 async fn outcome_unknown(
677 &self,
678 _: &McpCallContext,
679 _: &str,
680 _: &str,
681 _: &str,
682 ) -> Result<(), McpError> {
683 Ok(())
684 }
685 }
686
687 fn endpoint(url: &str) -> McpEndpoint {
688 McpEndpoint {
689 id: "docs".into(),
690 url: url.into(),
691 namespace: "docs".into(),
692 allowed_hosts: BTreeSet::from(["mcp.example.com".into()]),
693 allowed_tools: BTreeSet::from(["echo".into()]),
694 credential_ref: Some("secret-ref".into()),
695 timeout_ms: 100,
696 failure_threshold: 2,
697 recovery_ms: 1000,
698 }
699 }
700
701 #[tokio::test]
702 async fn rejects_ssrf_and_validates_namespaced_calls() {
703 assert!(
704 McpClient::new(endpoint("https://127.0.0.1/mcp"), Transport, Allow, Allow).is_err()
705 );
706 let client = McpClient::new(
707 endpoint("https://mcp.example.com/mcp"),
708 Transport,
709 Allow,
710 Allow,
711 )
712 .unwrap();
713 let tool = client.list_tools().await.unwrap().remove(0);
714 assert_eq!(tool.name, "docs.echo");
715 let context = McpCallContext {
716 tenant_id: "t".parse().unwrap(),
717 subject_id: "s".parse().unwrap(),
718 session_id: "session".parse().unwrap(),
719 run_id: "run".parse().unwrap(),
720 call_id: "call".parse().unwrap(),
721 source_event_seq: 1,
722 request_id: "r".parse().unwrap(),
723 };
724 assert!(client
725 .call(&context, &tool, serde_json::json!({}))
726 .await
727 .is_err());
728 assert_eq!(
729 client
730 .call(&context, &tool, serde_json::json!({"text":"ok"}))
731 .await
732 .unwrap()
733 .content["text"],
734 "ok"
735 );
736 }
737
738 #[tokio::test]
739 async fn controlled_call_observes_cancellation_before_transport() {
740 let client = McpClient::new(
741 endpoint("https://mcp.example.com/mcp"),
742 Transport,
743 Allow,
744 Allow,
745 )
746 .unwrap();
747 let cancellation = af_agent::CancellationToken::default();
748 cancellation.cancel();
749 let control = McpCallControl {
750 cancellation,
751 deadline: Instant::now() + Duration::from_secs(1),
752 };
753 assert!(matches!(
754 client.list_tools_controlled(&control).await,
755 Err(McpError::Cancelled)
756 ));
757 }
758
759 struct ToolErrorTransport;
760 #[async_trait]
761 impl McpTransport for ToolErrorTransport {
762 async fn list_tools(&self, _: &McpEndpoint) -> Result<Vec<McpTool>, McpError> {
763 Transport
764 .list_tools(&endpoint("https://mcp.example.com/mcp"))
765 .await
766 }
767 async fn call_tool(
768 &self,
769 _: &McpEndpoint,
770 _: &McpCallContext,
771 _: &str,
772 _: Value,
773 ) -> Result<McpToolResult, McpError> {
774 Ok(McpToolResult {
775 content: serde_json::json!({"content":[{"type":"text","text":"invalid input"}]}),
776 is_error: true,
777 })
778 }
779 }
780
781 #[tokio::test]
782 async fn application_tool_errors_do_not_open_transport_circuit() {
783 let client = McpClient::new(
784 endpoint("https://mcp.example.com/mcp"),
785 ToolErrorTransport,
786 Allow,
787 Allow,
788 )
789 .unwrap();
790 let tool = client.list_tools().await.unwrap().remove(0);
791 let context = McpCallContext {
792 tenant_id: "t".parse().unwrap(),
793 subject_id: "s".parse().unwrap(),
794 session_id: "session".parse().unwrap(),
795 run_id: "run".parse().unwrap(),
796 call_id: "call".parse().unwrap(),
797 source_event_seq: 1,
798 request_id: "request".parse().unwrap(),
799 };
800 for _ in 0..3 {
801 assert!(
802 client
803 .call(&context, &tool, serde_json::json!({"text":"bad"}))
804 .await
805 .unwrap()
806 .is_error
807 );
808 }
809 assert_eq!(client.list_tools().await.unwrap().len(), 1);
810 }
811
812 struct Catalog(Value);
813 #[async_trait]
814 impl McpTransport for Catalog {
815 async fn list_tools(&self, _: &McpEndpoint) -> Result<Vec<McpTool>, McpError> {
816 Ok(vec![McpTool {
817 name: "echo".into(),
818 description: String::new(),
819 input_schema: serde_json::json!({"type":"object"}),
820 output_schema: self.0.clone(),
821 risk_class: ToolRiskClass::Read,
822 execution_owner: ToolExecutionOwner::ProductHost,
823 }])
824 }
825 async fn call_tool(
826 &self,
827 _: &McpEndpoint,
828 _: &McpCallContext,
829 _: &str,
830 _: Value,
831 ) -> Result<McpToolResult, McpError> {
832 unreachable!()
833 }
834 }
835
836 #[tokio::test]
837 async fn rejects_missing_allowlisted_tools_and_invalid_output_schemas() {
838 let client = McpClient::new(
839 endpoint("https://mcp.example.com/mcp"),
840 Catalog(Value::String("invalid".into())),
841 Allow,
842 Allow,
843 )
844 .unwrap();
845 assert!(matches!(
846 client.list_tools().await,
847 Err(McpError::Rejected(_))
848 ));
849
850 let client = McpClient::new(
851 endpoint("https://mcp.example.com/mcp"),
852 Catalog(Value::Null),
853 Allow,
854 Allow,
855 )
856 .unwrap();
857 assert_eq!(
858 client.list_tools().await.unwrap()[0].output_schema,
859 Value::Null
860 );
861
862 let mut missing = endpoint("https://mcp.example.com/mcp");
863 missing.allowed_tools = BTreeSet::from(["missing".into()]);
864 let client = McpClient::new(missing, Transport, Allow, Allow).unwrap();
865 assert!(matches!(
866 client.list_tools().await,
867 Err(McpError::Rejected(_))
868 ));
869 }
870
871 struct InvalidOutput;
872 #[async_trait]
873 impl McpTransport for InvalidOutput {
874 async fn list_tools(&self, endpoint: &McpEndpoint) -> Result<Vec<McpTool>, McpError> {
875 Transport.list_tools(endpoint).await
876 }
877 async fn call_tool(
878 &self,
879 _: &McpEndpoint,
880 _: &McpCallContext,
881 _: &str,
882 _: Value,
883 ) -> Result<McpToolResult, McpError> {
884 Ok(McpToolResult {
885 content: serde_json::json!({"wrong":true}),
886 is_error: false,
887 })
888 }
889 }
890
891 #[tokio::test]
892 async fn validates_normalized_success_output() {
893 let client = McpClient::new(
894 endpoint("https://mcp.example.com/mcp"),
895 InvalidOutput,
896 Allow,
897 Allow,
898 )
899 .unwrap();
900 let tool = client.list_tools().await.unwrap().remove(0);
901 let context = McpCallContext {
902 tenant_id: "t".parse().unwrap(),
903 subject_id: "s".parse().unwrap(),
904 session_id: "session".parse().unwrap(),
905 run_id: "run".parse().unwrap(),
906 call_id: "invalid-output".parse().unwrap(),
907 source_event_seq: 1,
908 request_id: "request".parse().unwrap(),
909 };
910 assert!(matches!(
911 client
912 .call(&context, &tool, serde_json::json!({"text":"ok"}))
913 .await,
914 Err(McpError::Rejected(_))
915 ));
916 }
917}