Skip to main content

af_mcp_client/
lib.rs

1//! Fail-closed MCP client over Streamable HTTP: administrator-allowlisted
2//! HTTPS endpoints, credential references resolved by the host, product
3//! authorization before any I/O, tenant-scoped audit that makes calls
4//! idempotent, per-call timeouts and a circuit breaker. Exposed to the loop
5//! as [`McpClientPlugin`].
6
7#![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/// Administrator-allowlisted remote MCP endpoint and its guard rails.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct McpEndpoint {
33    /// Stable identifier of this record.
34    pub id: String,
35    /// Resolvable URL.
36    pub url: String,
37    /// Prefix under which remote tools are exposed to the model.
38    pub namespace: String,
39    /// Hosts the endpoint URL may resolve to; anything else fails closed.
40    pub allowed_hosts: BTreeSet<String>,
41    /// Remote tools that may be called; empty means none.
42    pub allowed_tools: BTreeSet<String>,
43    /// Reference resolved by the host's credential provider; never the secret itself.
44    pub credential_ref: Option<String>,
45    /// Per-call timeout in milliseconds.
46    pub timeout_ms: u64,
47    /// Consecutive failures that open the circuit breaker.
48    pub failure_threshold: u32,
49    /// Milliseconds the circuit stays open before a recovery probe.
50    pub recovery_ms: u64,
51}
52
53impl McpEndpoint {
54    /// Parse and check the endpoint: HTTPS only, allowlisted host, sane limits.
55    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/// Identity and Session position of one remote tool call, used for guards and audit.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct McpCallContext {
94    /// Tenant that owns this record.
95    pub tenant_id: TenantId,
96    /// Subject (user or service principal) acting on or owning this record.
97    pub subject_id: SubjectId,
98    /// Session this record belongs to.
99    pub session_id: SessionId,
100    /// Run this record belongs to.
101    pub run_id: RunId,
102    /// Tool call this record refers to.
103    pub call_id: ToolCallId,
104    /// Sequence of the Session event this was derived from.
105    pub source_event_seq: u64,
106    /// Transport request identity for tracing and idempotency.
107    pub request_id: RequestId,
108}
109
110/// Caller-owned cancellation and deadline for one MCP operation.
111#[derive(Clone)]
112pub struct McpCallControl {
113    /// Cancellation propagated from the Agent execution.
114    pub cancellation: af_agent::CancellationToken,
115    /// Absolute operation deadline.
116    pub deadline: Instant,
117}
118
119/// A tool advertised by the remote server.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct McpTool {
123    /// Display name.
124    pub name: String,
125    /// Human-readable description.
126    pub description: String,
127    /// JSON Schema for the tool arguments.
128    pub input_schema: Value,
129    /// JSON Schema for the tool result, or `null` when the server declares none.
130    #[serde(default)]
131    pub output_schema: Value,
132    /// Product-reviewed risk classification; omitted metadata fails closed.
133    #[serde(default)]
134    pub risk_class: ToolRiskClass,
135    /// Product-reviewed execution owner; omitted metadata fails closed.
136    #[serde(default)]
137    pub execution_owner: ToolExecutionOwner,
138}
139
140/// Result of one remote tool call.
141#[derive(Debug, Clone, PartialEq)]
142pub struct McpToolResult {
143    /// Content returned by the tool.
144    pub content: Value,
145    /// Whether the server reported the call as failed.
146    pub is_error: bool,
147}
148
149/// Audit decision for an idempotent call.
150#[derive(Debug, Clone, PartialEq)]
151pub enum McpCallClaim {
152    /// No prior record; perform the call.
153    Execute,
154    /// The call already completed; replay its result.
155    Completed(McpToolResult),
156    /// A prior attempt ended without a known outcome; do not repeat the side effect.
157    OutcomeUnknown,
158}
159
160/// Wire transport for MCP over Streamable HTTP.
161#[async_trait]
162pub trait McpTransport: Send + Sync {
163    /// Implementations must disable redirects and pin DNS resolution to public
164    /// addresses; this policy layer rejects literal and credential-bearing URLs.
165    async fn list_tools(&self, endpoint: &McpEndpoint) -> Result<Vec<McpTool>, McpError>;
166    /// Invoke a remote tool.
167    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/// Product authorization for remote calls, evaluated before any network I/O.
177#[async_trait]
178pub trait McpGuard: Send + Sync {
179    /// Allow or deny one call for this caller, endpoint and tool.
180    async fn authorize(
181        &self,
182        context: &McpCallContext,
183        endpoint: &str,
184        tool: &str,
185        arguments: &Value,
186    ) -> Result<(), McpError>;
187}
188
189/// Tenant-scoped audit log that also makes calls idempotent.
190#[async_trait]
191pub trait McpAudit: Send + Sync {
192    /// Record the intent to call and learn whether a prior attempt already settled.
193    async fn claim(
194        &self,
195        context: &McpCallContext,
196        endpoint: &str,
197        tool: &str,
198        arguments: &Value,
199    ) -> Result<McpCallClaim, McpError>;
200    /// Record a completed call and its result.
201    async fn complete(
202        &self,
203        context: &McpCallContext,
204        endpoint: &str,
205        tool: &str,
206        result: &McpToolResult,
207    ) -> Result<(), McpError>;
208    /// Record that the outcome is unknown so a retry never repeats the side effect blindly.
209    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
224/// Guarded, audited, circuit-broken client for one endpoint.
225pub struct McpClient<T, G, A> {
226    endpoint: McpEndpoint,
227    transport: T,
228    guard: G,
229    audit: A,
230    circuit: Mutex<Circuit>,
231}
232
233/// Trusted plugin exposing the endpoint's allowlisted tools to the loop.
234pub 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    /// Plugin over `client`.
241    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    /// Client for a validated endpoint.
372    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    /// List remote tools that pass the allowlist and schema checks.
384    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    /// List remote tools while observing caller cancellation and deadline.
390    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    /// Authorize, audit and invoke one remote tool with timeout and circuit breaking.
426    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    /// Authorize, audit and invoke one remote tool with caller cancellation and deadline.
438    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/// Failure from the MCP client.
591#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
592pub enum McpError {
593    /// MCP operation was cancelled by its caller.
594    #[error("MCP operation cancelled")]
595    Cancelled,
596    /// MCP request rejected.
597    #[error("MCP request rejected: {0}")]
598    Rejected(String),
599    /// MCP dependency unavailable.
600    #[error("MCP dependency unavailable: {0}")]
601    Unavailable(String),
602    /// MCP request timed out.
603    #[error("MCP request timed out")]
604    Timeout,
605    /// MCP circuit is open.
606    #[error("MCP circuit is open")]
607    CircuitOpen,
608    /// MCP tool outcome is unknown; verify remote state before retrying.
609    #[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}