1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use incurs::command::RequestContext;
6use incurs::tool::{ToolCallControl, ToolCallOptions, ToolCallOutcome, ToolCatalog};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ReplayPolicy {
14 #[default]
16 Log,
17 Reexecute,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ToolAnnotations {
24 pub read_only: Option<bool>,
26 pub destructive: Option<bool>,
28 pub idempotent: Option<bool>,
30 pub open_world: Option<bool>,
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ToolPolicy {
37 pub requires_approval: bool,
39 pub replay: ReplayPolicy,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ToolOrigin {
46 Local,
48 RemoteMcp,
50 OpenApi,
52}
53
54pub trait ToolPolicyResolver: Send + Sync {
56 fn resolve(&self, origin: ToolOrigin, annotations: &ToolAnnotations) -> ToolPolicy;
58}
59
60#[derive(Debug, Clone, Copy, Default)]
62pub struct DefaultToolPolicyResolver;
63
64impl ToolPolicyResolver for DefaultToolPolicyResolver {
65 fn resolve(&self, origin: ToolOrigin, annotations: &ToolAnnotations) -> ToolPolicy {
66 let safe_local_read = origin == ToolOrigin::Local
67 && annotations.read_only == Some(true)
68 && annotations.destructive != Some(true)
69 && annotations.open_world != Some(true);
70 ToolPolicy {
71 requires_approval: !safe_local_read,
72 replay: if safe_local_read {
73 ReplayPolicy::Reexecute
74 } else {
75 ReplayPolicy::Log
76 },
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ConnectorExample {
84 pub command: String,
86 pub description: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ConnectorTool {
93 pub name: String,
95 pub description: Option<String>,
97 pub input_schema: Value,
99 pub output_schema: Option<Value>,
101 pub instructions: Option<String>,
103 pub examples: Vec<ConnectorExample>,
105 pub annotations: ToolAnnotations,
107 pub policy: ToolPolicy,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ConnectorDescription {
114 pub name: String,
116 pub instructions: Option<String>,
118 pub tools: Vec<ConnectorTool>,
120}
121
122#[derive(Clone)]
124pub struct ToolContext {
125 pub execution_id: String,
127 pub control: ToolCallControl,
129 pub request: Option<RequestContext>,
131}
132
133#[async_trait]
135pub trait Connector: Send + Sync {
136 async fn describe(&self) -> Result<ConnectorDescription, String>;
138
139 async fn execute(
141 &self,
142 method: &str,
143 arguments: Value,
144 context: &ToolContext,
145 ) -> Result<Value, String>;
146
147 async fn revert(
149 &self,
150 _method: &str,
151 _arguments: Value,
152 _result: Value,
153 _context: &ToolContext,
154 ) -> Result<bool, String> {
155 Ok(false)
156 }
157
158 async fn pass_ended(&self, _execution_id: &str, _status: &str) {}
160
161 async fn execution_ended(&self, _execution_id: &str, _status: &str) {}
163}
164
165#[derive(Clone)]
167pub struct IncurConnector {
168 catalog: ToolCatalog,
169 name: String,
170 instructions: Option<String>,
171 options: ToolCallOptions,
172 policy: Arc<dyn ToolPolicyResolver>,
173}
174
175impl IncurConnector {
176 pub fn new(catalog: ToolCatalog) -> Self {
178 let name = sanitize_namespace(catalog.name());
179 Self {
180 catalog,
181 name,
182 instructions: None,
183 options: ToolCallOptions::default(),
184 policy: Arc::new(DefaultToolPolicyResolver),
185 }
186 }
187
188 pub fn with_name(mut self, name: impl Into<String>) -> Self {
190 self.name = name.into();
191 self
192 }
193
194 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
196 self.instructions = Some(instructions.into());
197 self
198 }
199
200 pub fn with_call_options(mut self, options: ToolCallOptions) -> Self {
202 self.options = options;
203 self
204 }
205
206 pub fn with_policy_resolver(mut self, resolver: Arc<dyn ToolPolicyResolver>) -> Self {
208 self.policy = resolver;
209 self
210 }
211}
212
213#[async_trait]
214impl Connector for IncurConnector {
215 async fn describe(&self) -> Result<ConnectorDescription, String> {
216 Ok(ConnectorDescription {
217 name: self.name.clone(),
218 instructions: self.instructions.clone(),
219 tools: self
220 .catalog
221 .definitions()
222 .into_iter()
223 .map(|tool| {
224 let annotations = ToolAnnotations {
225 read_only: tool
226 .annotations
227 .as_ref()
228 .and_then(|annotations| annotations.read_only_hint),
229 destructive: tool
230 .annotations
231 .as_ref()
232 .and_then(|annotations| annotations.destructive_hint),
233 idempotent: tool
234 .annotations
235 .as_ref()
236 .and_then(|annotations| annotations.idempotent_hint),
237 open_world: tool
238 .annotations
239 .as_ref()
240 .and_then(|annotations| annotations.open_world_hint),
241 };
242 ConnectorTool {
243 name: tool.name,
244 description: (!tool.description.is_empty()).then_some(tool.description),
245 input_schema: tool.input_schema,
246 output_schema: tool.output_schema,
247 instructions: tool.instructions,
248 examples: tool
249 .examples
250 .into_iter()
251 .map(|example| ConnectorExample {
252 command: example.command,
253 description: example.description,
254 })
255 .collect(),
256 policy: self.policy.resolve(ToolOrigin::Local, &annotations),
257 annotations,
258 }
259 })
260 .collect(),
261 })
262 }
263
264 async fn execute(
265 &self,
266 method: &str,
267 arguments: Value,
268 context: &ToolContext,
269 ) -> Result<Value, String> {
270 let arguments = arguments
271 .as_object()
272 .ok_or_else(|| format!("Arguments to {method} must be an object"))?
273 .iter()
274 .map(|(key, value)| (key.clone(), value.clone()))
275 .collect::<BTreeMap<_, _>>();
276 let mut options = self.options.clone();
277 options.control = context.control.clone();
278 if context.request.is_some() {
279 options.request = context.request.clone();
280 }
281 match self.catalog.call(method, arguments, options).await {
282 ToolCallOutcome::Ok { data, cta } => {
283 if cta.is_some() {
284 Ok(serde_json::json!({ "data": data, "cta": cta }))
285 } else {
286 Ok(data)
287 }
288 }
289 ToolCallOutcome::Error {
290 code,
291 message,
292 retryable,
293 field_errors,
294 cta,
295 exit_code,
296 } => Err(serde_json::json!({
297 "code": code,
298 "message": message,
299 "retryable": retryable,
300 "fieldErrors": field_errors,
301 "cta": cta,
302 "exitCode": exit_code,
303 })
304 .to_string()),
305 }
306 }
307}
308
309pub fn sanitize_namespace(value: &str) -> String {
311 let mut result = String::new();
312 for (index, ch) in value.chars().enumerate() {
313 if (index == 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphabetic()))
314 || (index > 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()))
315 {
316 result.push('_');
317 } else {
318 result.push(ch);
319 }
320 }
321 if result.is_empty() {
322 "tools".to_string()
323 } else {
324 result
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct McpTool {
331 pub name: String,
333 pub description: Option<String>,
335 pub input_schema: Value,
337 pub output_schema: Option<Value>,
339 pub annotations: Option<incurs::command::McpAnnotations>,
341}
342
343#[async_trait]
345pub trait McpClient: Send + Sync {
346 async fn list_tools(&self) -> Result<Vec<McpTool>, String>;
348
349 async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, String>;
351
352 async fn call_tool_cancellable(
360 &self,
361 name: &str,
362 arguments: Value,
363 cancellation: &tokio_util::sync::CancellationToken,
364 ) -> Result<Value, String> {
365 tokio::select! {
366 biased;
367 () = cancellation.cancelled() => Err("Call cancelled".to_string()),
368 result = self.call_tool(name, arguments) => result,
369 }
370 }
371}
372
373pub struct McpConnector {
375 name: String,
376 instructions: Option<String>,
377 client: Arc<dyn McpClient>,
378 tools: tokio::sync::OnceCell<Vec<(String, McpTool)>>,
379 policy: Arc<dyn ToolPolicyResolver>,
380}
381
382impl McpConnector {
383 pub fn new(name: impl Into<String>, client: Arc<dyn McpClient>) -> Self {
385 Self {
386 name: name.into(),
387 instructions: None,
388 client,
389 tools: tokio::sync::OnceCell::new(),
390 policy: Arc::new(DefaultToolPolicyResolver),
391 }
392 }
393
394 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
396 self.instructions = Some(instructions.into());
397 self
398 }
399
400 pub fn with_policy_resolver(mut self, resolver: Arc<dyn ToolPolicyResolver>) -> Self {
402 self.policy = resolver;
403 self
404 }
405
406 async fn tools(&self) -> Result<&Vec<(String, McpTool)>, String> {
407 self.tools
408 .get_or_try_init(|| async {
409 let mut names = BTreeMap::new();
410 let mut tools = Vec::new();
411 for tool in self.client.list_tools().await? {
412 let name = sanitize_namespace(&tool.name);
413 if let Some(existing) = names.insert(name.clone(), tool.name.clone()) {
414 return Err(format!(
415 "MCP tools \"{existing}\" and \"{}\" both map to \"{name}\"",
416 tool.name
417 ));
418 }
419 tools.push((name, tool));
420 }
421 Ok(tools)
422 })
423 .await
424 }
425}
426
427#[async_trait]
428impl Connector for McpConnector {
429 async fn describe(&self) -> Result<ConnectorDescription, String> {
430 Ok(ConnectorDescription {
431 name: self.name.clone(),
432 instructions: self.instructions.clone(),
433 tools: self
434 .tools()
435 .await?
436 .iter()
437 .map(|(name, tool)| {
438 let annotations = ToolAnnotations {
439 read_only: tool
440 .annotations
441 .as_ref()
442 .and_then(|annotations| annotations.read_only_hint),
443 destructive: tool
444 .annotations
445 .as_ref()
446 .and_then(|annotations| annotations.destructive_hint),
447 idempotent: tool
448 .annotations
449 .as_ref()
450 .and_then(|annotations| annotations.idempotent_hint),
451 open_world: tool
452 .annotations
453 .as_ref()
454 .and_then(|annotations| annotations.open_world_hint),
455 };
456 ConnectorTool {
457 name: name.clone(),
458 description: tool.description.clone(),
459 input_schema: tool.input_schema.clone(),
460 output_schema: tool.output_schema.clone(),
461 instructions: None,
462 examples: Vec::new(),
463 policy: self.policy.resolve(ToolOrigin::RemoteMcp, &annotations),
464 annotations,
465 }
466 })
467 .collect(),
468 })
469 }
470
471 async fn execute(
472 &self,
473 method: &str,
474 arguments: Value,
475 context: &ToolContext,
476 ) -> Result<Value, String> {
477 let (_, tool) = self
478 .tools()
479 .await?
480 .iter()
481 .find(|(name, _)| name == method)
482 .ok_or_else(|| format!("Tool \"{method}\" not found on {}", self.name))?;
483 self.client
484 .call_tool_cancellable(&tool.name, arguments, &context.control.cancellation)
485 .await
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct OpenApiRequest {
492 pub path: String,
494 pub method: String,
496 pub parameters: BTreeMap<String, Value>,
498 pub body: Option<Value>,
500 pub headers: BTreeMap<String, String>,
502}
503
504#[async_trait]
506pub trait OpenApiClient: Send + Sync {
507 async fn specification(&self) -> Result<Value, String>;
509
510 async fn request(&self, request: OpenApiRequest) -> Result<Value, String>;
512}
513
514#[derive(Clone)]
515struct OpenApiOperation {
516 name: String,
517 method: String,
518 path: String,
519 description: String,
520 input_schema: Value,
521 parameters: Vec<(String, String)>,
522}
523
524pub struct OpenApiConnector {
526 name: String,
527 instructions: Option<String>,
528 client: Arc<dyn OpenApiClient>,
529 operations: tokio::sync::OnceCell<Vec<OpenApiOperation>>,
530 policy: Arc<dyn ToolPolicyResolver>,
531}
532
533impl OpenApiConnector {
534 pub fn new(name: impl Into<String>, client: Arc<dyn OpenApiClient>) -> Self {
536 Self {
537 name: name.into(),
538 instructions: None,
539 client,
540 operations: tokio::sync::OnceCell::new(),
541 policy: Arc::new(DefaultToolPolicyResolver),
542 }
543 }
544
545 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
547 self.instructions = Some(instructions.into());
548 self
549 }
550
551 pub fn with_policy_resolver(mut self, resolver: Arc<dyn ToolPolicyResolver>) -> Self {
553 self.policy = resolver;
554 self
555 }
556
557 async fn operations(&self) -> Result<&Vec<OpenApiOperation>, String> {
558 self.operations
559 .get_or_try_init(|| async {
560 derive_openapi_operations(&self.client.specification().await?)
561 })
562 .await
563 }
564}
565
566#[async_trait]
567impl Connector for OpenApiConnector {
568 async fn describe(&self) -> Result<ConnectorDescription, String> {
569 let mut tools = vec![ConnectorTool {
570 name: "request".to_string(),
571 description: Some(
572 "Perform an authenticated request when no derived operation fits.".to_string(),
573 ),
574 input_schema: serde_json::json!({
575 "type": "object",
576 "properties": {
577 "path": {"type": "string"},
578 "method": {"type": "string"},
579 "parameters": {"type": "object", "additionalProperties": true},
580 "body": {},
581 "headers": {"type": "object", "additionalProperties": {"type": "string"}}
582 },
583 "required": ["path"]
584 }),
585 output_schema: None,
586 instructions: None,
587 examples: Vec::new(),
588 annotations: ToolAnnotations {
589 open_world: Some(true),
590 ..ToolAnnotations::default()
591 },
592 policy: ToolPolicy {
593 requires_approval: true,
594 replay: ReplayPolicy::Log,
595 },
596 }];
597 tools.extend(self.operations().await?.iter().map(|operation| {
598 let annotations = ToolAnnotations {
599 read_only: Some(operation.method == "get" || operation.method == "head"),
600 open_world: Some(true),
601 ..ToolAnnotations::default()
602 };
603 ConnectorTool {
604 name: operation.name.clone(),
605 description: Some(operation.description.clone()),
606 input_schema: operation.input_schema.clone(),
607 output_schema: None,
608 instructions: None,
609 examples: Vec::new(),
610 policy: self.policy.resolve(ToolOrigin::OpenApi, &annotations),
611 annotations,
612 }
613 }));
614 Ok(ConnectorDescription {
615 name: self.name.clone(),
616 instructions: self.instructions.clone(),
617 tools,
618 })
619 }
620
621 async fn execute(
622 &self,
623 method: &str,
624 arguments: Value,
625 _context: &ToolContext,
626 ) -> Result<Value, String> {
627 if method == "request" {
628 return self.client.request(parse_raw_request(arguments)?).await;
629 }
630 let operation = self
631 .operations()
632 .await?
633 .iter()
634 .find(|operation| operation.name == method)
635 .ok_or_else(|| format!("Tool \"{method}\" not found on {}", self.name))?;
636 self.client
637 .request(operation_request(operation, arguments)?)
638 .await
639 }
640}
641
642fn derive_openapi_operations(document: &Value) -> Result<Vec<OpenApiOperation>, String> {
643 let Some(paths) = document.get("paths").and_then(Value::as_object) else {
644 return Ok(Vec::new());
645 };
646 let mut used = BTreeMap::new();
647 let mut operations = Vec::new();
648 for (path, item) in paths {
649 let Some(item) = item.as_object() else {
650 continue;
651 };
652 for method in ["get", "put", "post", "delete", "patch", "options", "head"] {
653 let Some(operation) = item.get(method).and_then(Value::as_object) else {
654 continue;
655 };
656 let source_name = operation
657 .get("operationId")
658 .and_then(Value::as_str)
659 .map(str::to_string)
660 .unwrap_or_else(|| format!("{method}_{path}"));
661 let name = sanitize_namespace(&source_name);
662 if name == "request" || name == "spec" || used.insert(name.clone(), path).is_some() {
663 continue;
664 }
665 let mut properties = serde_json::Map::new();
666 let mut required = Vec::new();
667 let mut parameters = Vec::new();
668 for parameter in operation
669 .get("parameters")
670 .and_then(Value::as_array)
671 .into_iter()
672 .flatten()
673 {
674 let Some(parameter) = parameter.as_object() else {
675 continue;
676 };
677 let Some(parameter_name) = parameter.get("name").and_then(Value::as_str) else {
678 continue;
679 };
680 let location = parameter
681 .get("in")
682 .and_then(Value::as_str)
683 .unwrap_or("query");
684 properties.insert(
685 parameter_name.to_string(),
686 parameter
687 .get("schema")
688 .cloned()
689 .unwrap_or_else(|| serde_json::json!({})),
690 );
691 parameters.push((parameter_name.to_string(), location.to_string()));
692 if parameter.get("required").and_then(Value::as_bool) == Some(true) {
693 required.push(Value::String(parameter_name.to_string()));
694 }
695 }
696 if let Some(body) = operation
697 .get("requestBody")
698 .and_then(|value| value.get("content"))
699 .and_then(|value| value.get("application/json"))
700 .and_then(|value| value.get("schema"))
701 .cloned()
702 {
703 properties.insert("body".to_string(), body);
704 if operation
705 .get("requestBody")
706 .and_then(|value| value.get("required"))
707 .and_then(Value::as_bool)
708 == Some(true)
709 {
710 required.push(Value::String("body".to_string()));
711 }
712 }
713 operations.push(OpenApiOperation {
714 name,
715 method: method.to_string(),
716 path: path.clone(),
717 description: operation
718 .get("summary")
719 .or_else(|| operation.get("description"))
720 .and_then(Value::as_str)
721 .map(str::to_string)
722 .unwrap_or_else(|| format!("{} {path}", method.to_ascii_uppercase())),
723 input_schema: serde_json::json!({
724 "type": "object",
725 "properties": properties,
726 "required": required,
727 }),
728 parameters,
729 });
730 }
731 }
732 Ok(operations)
733}
734
735fn operation_request(
736 operation: &OpenApiOperation,
737 arguments: Value,
738) -> Result<OpenApiRequest, String> {
739 let input = arguments
740 .as_object()
741 .ok_or_else(|| format!("Arguments to {} must be an object", operation.name))?;
742 let mut path = operation.path.clone();
743 let mut parameters = BTreeMap::new();
744 let mut headers = BTreeMap::new();
745 for (name, location) in &operation.parameters {
746 let Some(value) = input.get(name) else {
747 continue;
748 };
749 match location.as_str() {
750 "path" => {
751 path = path.replace(
752 &format!("{{{name}}}"),
753 value.as_str().unwrap_or(&value.to_string()),
754 )
755 }
756 "header" => {
757 headers.insert(
758 name.clone(),
759 value
760 .as_str()
761 .map(str::to_string)
762 .unwrap_or_else(|| value.to_string()),
763 );
764 }
765 "query" => {
766 parameters.insert(name.clone(), value.clone());
767 }
768 _ => {}
769 }
770 }
771 Ok(OpenApiRequest {
772 path,
773 method: operation.method.clone(),
774 parameters,
775 body: input.get("body").cloned(),
776 headers,
777 })
778}
779
780fn parse_raw_request(arguments: Value) -> Result<OpenApiRequest, String> {
781 let input = arguments
782 .as_object()
783 .ok_or_else(|| "Arguments to request must be an object".to_string())?;
784 Ok(OpenApiRequest {
785 path: input
786 .get("path")
787 .and_then(Value::as_str)
788 .ok_or_else(|| "request.path is required".to_string())?
789 .to_string(),
790 method: input
791 .get("method")
792 .and_then(Value::as_str)
793 .unwrap_or("GET")
794 .to_string(),
795 parameters: object_map(input.get("parameters")),
796 body: input.get("body").cloned(),
797 headers: input
798 .get("headers")
799 .and_then(Value::as_object)
800 .into_iter()
801 .flatten()
802 .map(|(key, value)| {
803 (
804 key.clone(),
805 value
806 .as_str()
807 .map(str::to_string)
808 .unwrap_or_else(|| value.to_string()),
809 )
810 })
811 .collect(),
812 })
813}
814
815fn object_map(value: Option<&Value>) -> BTreeMap<String, Value> {
816 value
817 .and_then(Value::as_object)
818 .into_iter()
819 .flatten()
820 .map(|(key, value)| (key.clone(), value.clone()))
821 .collect()
822}
823
824#[cfg(test)]
825mod policy_tests {
826 use super::*;
827
828 #[test]
829 fn only_safe_local_reads_skip_approval() {
830 let resolver = DefaultToolPolicyResolver;
831 let read = ToolAnnotations {
832 read_only: Some(true),
833 ..ToolAnnotations::default()
834 };
835 assert_eq!(
836 resolver.resolve(ToolOrigin::Local, &read),
837 ToolPolicy {
838 requires_approval: false,
839 replay: ReplayPolicy::Reexecute,
840 }
841 );
842 assert!(
843 resolver
844 .resolve(ToolOrigin::RemoteMcp, &read)
845 .requires_approval
846 );
847 assert!(
848 resolver
849 .resolve(
850 ToolOrigin::Local,
851 &ToolAnnotations {
852 open_world: Some(true),
853 ..read
854 }
855 )
856 .requires_approval
857 );
858 }
859}
860
861#[cfg(test)]
862mod mcp_tests {
863 use std::sync::Arc;
864 use std::sync::atomic::{AtomicUsize, Ordering};
865
866 use async_trait::async_trait;
867 use incurs::tool::ToolCallControl;
868 use serde_json::{Value, json};
869
870 use super::{Connector, McpClient, McpConnector, McpTool, ToolContext};
871
872 struct HangingClient {
878 started: Arc<AtomicUsize>,
879 }
880
881 #[async_trait]
882 impl McpClient for HangingClient {
883 async fn list_tools(&self) -> Result<Vec<McpTool>, String> {
884 Ok(vec![McpTool {
885 name: "wait".to_string(),
886 description: None,
887 input_schema: json!({"type": "object"}),
888 output_schema: None,
889 annotations: None,
890 }])
891 }
892
893 async fn call_tool(&self, _name: &str, _arguments: Value) -> Result<Value, String> {
894 self.started.fetch_add(1, Ordering::SeqCst);
895 std::future::pending::<()>().await;
897 unreachable!("pending future resolved")
898 }
899 }
900
901 fn context(control: ToolCallControl) -> ToolContext {
902 ToolContext {
903 execution_id: "exec_test".to_string(),
904 control,
905 request: None,
906 }
907 }
908
909 #[tokio::test]
910 async fn cancellation_reaches_a_client_that_only_implements_call_tool() {
911 let started = Arc::new(AtomicUsize::new(0));
912 let connector = McpConnector::new(
913 "hang",
914 Arc::new(HangingClient {
915 started: Arc::clone(&started),
916 }),
917 );
918 let control = ToolCallControl::default();
919 let cancellation = control.cancellation.clone();
920
921 let started_probe = Arc::clone(&started);
924 tokio::spawn(async move {
925 while started_probe.load(Ordering::SeqCst) == 0 {
926 tokio::task::yield_now().await;
927 }
928 cancellation.cancel();
929 });
930
931 let result = connector
932 .execute("wait", json!({}), &context(control))
933 .await;
934
935 assert_eq!(started.load(Ordering::SeqCst), 1, "the call never started");
936 assert_eq!(result, Err("Call cancelled".to_string()));
937 }
938
939 #[tokio::test]
940 async fn an_uncancelled_call_is_unaffected() {
941 struct Echo;
942
943 #[async_trait]
944 impl McpClient for Echo {
945 async fn list_tools(&self) -> Result<Vec<McpTool>, String> {
946 Ok(vec![McpTool {
947 name: "echo".to_string(),
948 description: None,
949 input_schema: json!({"type": "object"}),
950 output_schema: None,
951 annotations: None,
952 }])
953 }
954
955 async fn call_tool(&self, _name: &str, arguments: Value) -> Result<Value, String> {
956 Ok(arguments)
957 }
958 }
959
960 let connector = McpConnector::new("echo", Arc::new(Echo));
961 let result = connector
962 .execute(
963 "echo",
964 json!({"v": 1}),
965 &context(ToolCallControl::default()),
966 )
967 .await;
968
969 assert_eq!(result, Ok(json!({"v": 1})));
970 }
971}