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
353pub struct McpConnector {
355 name: String,
356 instructions: Option<String>,
357 client: Arc<dyn McpClient>,
358 tools: tokio::sync::OnceCell<Vec<(String, McpTool)>>,
359 policy: Arc<dyn ToolPolicyResolver>,
360}
361
362impl McpConnector {
363 pub fn new(name: impl Into<String>, client: Arc<dyn McpClient>) -> Self {
365 Self {
366 name: name.into(),
367 instructions: None,
368 client,
369 tools: tokio::sync::OnceCell::new(),
370 policy: Arc::new(DefaultToolPolicyResolver),
371 }
372 }
373
374 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
376 self.instructions = Some(instructions.into());
377 self
378 }
379
380 pub fn with_policy_resolver(mut self, resolver: Arc<dyn ToolPolicyResolver>) -> Self {
382 self.policy = resolver;
383 self
384 }
385
386 async fn tools(&self) -> Result<&Vec<(String, McpTool)>, String> {
387 self.tools
388 .get_or_try_init(|| async {
389 let mut names = BTreeMap::new();
390 let mut tools = Vec::new();
391 for tool in self.client.list_tools().await? {
392 let name = sanitize_namespace(&tool.name);
393 if let Some(existing) = names.insert(name.clone(), tool.name.clone()) {
394 return Err(format!(
395 "MCP tools \"{existing}\" and \"{}\" both map to \"{name}\"",
396 tool.name
397 ));
398 }
399 tools.push((name, tool));
400 }
401 Ok(tools)
402 })
403 .await
404 }
405}
406
407#[async_trait]
408impl Connector for McpConnector {
409 async fn describe(&self) -> Result<ConnectorDescription, String> {
410 Ok(ConnectorDescription {
411 name: self.name.clone(),
412 instructions: self.instructions.clone(),
413 tools: self
414 .tools()
415 .await?
416 .iter()
417 .map(|(name, tool)| {
418 let annotations = ToolAnnotations {
419 read_only: tool
420 .annotations
421 .as_ref()
422 .and_then(|annotations| annotations.read_only_hint),
423 destructive: tool
424 .annotations
425 .as_ref()
426 .and_then(|annotations| annotations.destructive_hint),
427 idempotent: tool
428 .annotations
429 .as_ref()
430 .and_then(|annotations| annotations.idempotent_hint),
431 open_world: tool
432 .annotations
433 .as_ref()
434 .and_then(|annotations| annotations.open_world_hint),
435 };
436 ConnectorTool {
437 name: name.clone(),
438 description: tool.description.clone(),
439 input_schema: tool.input_schema.clone(),
440 output_schema: tool.output_schema.clone(),
441 instructions: None,
442 examples: Vec::new(),
443 policy: self.policy.resolve(ToolOrigin::RemoteMcp, &annotations),
444 annotations,
445 }
446 })
447 .collect(),
448 })
449 }
450
451 async fn execute(
452 &self,
453 method: &str,
454 arguments: Value,
455 _context: &ToolContext,
456 ) -> Result<Value, String> {
457 let (_, tool) = self
458 .tools()
459 .await?
460 .iter()
461 .find(|(name, _)| name == method)
462 .ok_or_else(|| format!("Tool \"{method}\" not found on {}", self.name))?;
463 self.client.call_tool(&tool.name, arguments).await
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct OpenApiRequest {
470 pub path: String,
472 pub method: String,
474 pub parameters: BTreeMap<String, Value>,
476 pub body: Option<Value>,
478 pub headers: BTreeMap<String, String>,
480}
481
482#[async_trait]
484pub trait OpenApiClient: Send + Sync {
485 async fn specification(&self) -> Result<Value, String>;
487
488 async fn request(&self, request: OpenApiRequest) -> Result<Value, String>;
490}
491
492#[derive(Clone)]
493struct OpenApiOperation {
494 name: String,
495 method: String,
496 path: String,
497 description: String,
498 input_schema: Value,
499 parameters: Vec<(String, String)>,
500}
501
502pub struct OpenApiConnector {
504 name: String,
505 instructions: Option<String>,
506 client: Arc<dyn OpenApiClient>,
507 operations: tokio::sync::OnceCell<Vec<OpenApiOperation>>,
508 policy: Arc<dyn ToolPolicyResolver>,
509}
510
511impl OpenApiConnector {
512 pub fn new(name: impl Into<String>, client: Arc<dyn OpenApiClient>) -> Self {
514 Self {
515 name: name.into(),
516 instructions: None,
517 client,
518 operations: tokio::sync::OnceCell::new(),
519 policy: Arc::new(DefaultToolPolicyResolver),
520 }
521 }
522
523 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
525 self.instructions = Some(instructions.into());
526 self
527 }
528
529 pub fn with_policy_resolver(mut self, resolver: Arc<dyn ToolPolicyResolver>) -> Self {
531 self.policy = resolver;
532 self
533 }
534
535 async fn operations(&self) -> Result<&Vec<OpenApiOperation>, String> {
536 self.operations
537 .get_or_try_init(|| async {
538 derive_openapi_operations(&self.client.specification().await?)
539 })
540 .await
541 }
542}
543
544#[async_trait]
545impl Connector for OpenApiConnector {
546 async fn describe(&self) -> Result<ConnectorDescription, String> {
547 let mut tools = vec![ConnectorTool {
548 name: "request".to_string(),
549 description: Some(
550 "Perform an authenticated request when no derived operation fits.".to_string(),
551 ),
552 input_schema: serde_json::json!({
553 "type": "object",
554 "properties": {
555 "path": {"type": "string"},
556 "method": {"type": "string"},
557 "parameters": {"type": "object", "additionalProperties": true},
558 "body": {},
559 "headers": {"type": "object", "additionalProperties": {"type": "string"}}
560 },
561 "required": ["path"]
562 }),
563 output_schema: None,
564 instructions: None,
565 examples: Vec::new(),
566 annotations: ToolAnnotations {
567 open_world: Some(true),
568 ..ToolAnnotations::default()
569 },
570 policy: ToolPolicy {
571 requires_approval: true,
572 replay: ReplayPolicy::Log,
573 },
574 }];
575 tools.extend(self.operations().await?.iter().map(|operation| {
576 let annotations = ToolAnnotations {
577 read_only: Some(operation.method == "get" || operation.method == "head"),
578 open_world: Some(true),
579 ..ToolAnnotations::default()
580 };
581 ConnectorTool {
582 name: operation.name.clone(),
583 description: Some(operation.description.clone()),
584 input_schema: operation.input_schema.clone(),
585 output_schema: None,
586 instructions: None,
587 examples: Vec::new(),
588 policy: self.policy.resolve(ToolOrigin::OpenApi, &annotations),
589 annotations,
590 }
591 }));
592 Ok(ConnectorDescription {
593 name: self.name.clone(),
594 instructions: self.instructions.clone(),
595 tools,
596 })
597 }
598
599 async fn execute(
600 &self,
601 method: &str,
602 arguments: Value,
603 _context: &ToolContext,
604 ) -> Result<Value, String> {
605 if method == "request" {
606 return self.client.request(parse_raw_request(arguments)?).await;
607 }
608 let operation = self
609 .operations()
610 .await?
611 .iter()
612 .find(|operation| operation.name == method)
613 .ok_or_else(|| format!("Tool \"{method}\" not found on {}", self.name))?;
614 self.client
615 .request(operation_request(operation, arguments)?)
616 .await
617 }
618}
619
620fn derive_openapi_operations(document: &Value) -> Result<Vec<OpenApiOperation>, String> {
621 let Some(paths) = document.get("paths").and_then(Value::as_object) else {
622 return Ok(Vec::new());
623 };
624 let mut used = BTreeMap::new();
625 let mut operations = Vec::new();
626 for (path, item) in paths {
627 let Some(item) = item.as_object() else {
628 continue;
629 };
630 for method in ["get", "put", "post", "delete", "patch", "options", "head"] {
631 let Some(operation) = item.get(method).and_then(Value::as_object) else {
632 continue;
633 };
634 let source_name = operation
635 .get("operationId")
636 .and_then(Value::as_str)
637 .map(str::to_string)
638 .unwrap_or_else(|| format!("{method}_{path}"));
639 let name = sanitize_namespace(&source_name);
640 if name == "request" || name == "spec" || used.insert(name.clone(), path).is_some() {
641 continue;
642 }
643 let mut properties = serde_json::Map::new();
644 let mut required = Vec::new();
645 let mut parameters = Vec::new();
646 for parameter in operation
647 .get("parameters")
648 .and_then(Value::as_array)
649 .into_iter()
650 .flatten()
651 {
652 let Some(parameter) = parameter.as_object() else {
653 continue;
654 };
655 let Some(parameter_name) = parameter.get("name").and_then(Value::as_str) else {
656 continue;
657 };
658 let location = parameter
659 .get("in")
660 .and_then(Value::as_str)
661 .unwrap_or("query");
662 properties.insert(
663 parameter_name.to_string(),
664 parameter
665 .get("schema")
666 .cloned()
667 .unwrap_or_else(|| serde_json::json!({})),
668 );
669 parameters.push((parameter_name.to_string(), location.to_string()));
670 if parameter.get("required").and_then(Value::as_bool) == Some(true) {
671 required.push(Value::String(parameter_name.to_string()));
672 }
673 }
674 if let Some(body) = operation
675 .get("requestBody")
676 .and_then(|value| value.get("content"))
677 .and_then(|value| value.get("application/json"))
678 .and_then(|value| value.get("schema"))
679 .cloned()
680 {
681 properties.insert("body".to_string(), body);
682 if operation
683 .get("requestBody")
684 .and_then(|value| value.get("required"))
685 .and_then(Value::as_bool)
686 == Some(true)
687 {
688 required.push(Value::String("body".to_string()));
689 }
690 }
691 operations.push(OpenApiOperation {
692 name,
693 method: method.to_string(),
694 path: path.clone(),
695 description: operation
696 .get("summary")
697 .or_else(|| operation.get("description"))
698 .and_then(Value::as_str)
699 .map(str::to_string)
700 .unwrap_or_else(|| format!("{} {path}", method.to_ascii_uppercase())),
701 input_schema: serde_json::json!({
702 "type": "object",
703 "properties": properties,
704 "required": required,
705 }),
706 parameters,
707 });
708 }
709 }
710 Ok(operations)
711}
712
713fn operation_request(
714 operation: &OpenApiOperation,
715 arguments: Value,
716) -> Result<OpenApiRequest, String> {
717 let input = arguments
718 .as_object()
719 .ok_or_else(|| format!("Arguments to {} must be an object", operation.name))?;
720 let mut path = operation.path.clone();
721 let mut parameters = BTreeMap::new();
722 let mut headers = BTreeMap::new();
723 for (name, location) in &operation.parameters {
724 let Some(value) = input.get(name) else {
725 continue;
726 };
727 match location.as_str() {
728 "path" => {
729 path = path.replace(
730 &format!("{{{name}}}"),
731 value.as_str().unwrap_or(&value.to_string()),
732 )
733 }
734 "header" => {
735 headers.insert(
736 name.clone(),
737 value
738 .as_str()
739 .map(str::to_string)
740 .unwrap_or_else(|| value.to_string()),
741 );
742 }
743 "query" => {
744 parameters.insert(name.clone(), value.clone());
745 }
746 _ => {}
747 }
748 }
749 Ok(OpenApiRequest {
750 path,
751 method: operation.method.clone(),
752 parameters,
753 body: input.get("body").cloned(),
754 headers,
755 })
756}
757
758fn parse_raw_request(arguments: Value) -> Result<OpenApiRequest, String> {
759 let input = arguments
760 .as_object()
761 .ok_or_else(|| "Arguments to request must be an object".to_string())?;
762 Ok(OpenApiRequest {
763 path: input
764 .get("path")
765 .and_then(Value::as_str)
766 .ok_or_else(|| "request.path is required".to_string())?
767 .to_string(),
768 method: input
769 .get("method")
770 .and_then(Value::as_str)
771 .unwrap_or("GET")
772 .to_string(),
773 parameters: object_map(input.get("parameters")),
774 body: input.get("body").cloned(),
775 headers: input
776 .get("headers")
777 .and_then(Value::as_object)
778 .into_iter()
779 .flatten()
780 .map(|(key, value)| {
781 (
782 key.clone(),
783 value
784 .as_str()
785 .map(str::to_string)
786 .unwrap_or_else(|| value.to_string()),
787 )
788 })
789 .collect(),
790 })
791}
792
793fn object_map(value: Option<&Value>) -> BTreeMap<String, Value> {
794 value
795 .and_then(Value::as_object)
796 .into_iter()
797 .flatten()
798 .map(|(key, value)| (key.clone(), value.clone()))
799 .collect()
800}
801
802#[cfg(test)]
803mod policy_tests {
804 use super::*;
805
806 #[test]
807 fn only_safe_local_reads_skip_approval() {
808 let resolver = DefaultToolPolicyResolver;
809 let read = ToolAnnotations {
810 read_only: Some(true),
811 ..ToolAnnotations::default()
812 };
813 assert_eq!(
814 resolver.resolve(ToolOrigin::Local, &read),
815 ToolPolicy {
816 requires_approval: false,
817 replay: ReplayPolicy::Reexecute,
818 }
819 );
820 assert!(
821 resolver
822 .resolve(ToolOrigin::RemoteMcp, &read)
823 .requires_approval
824 );
825 assert!(
826 resolver
827 .resolve(
828 ToolOrigin::Local,
829 &ToolAnnotations {
830 open_world: Some(true),
831 ..read
832 }
833 )
834 .requires_approval
835 );
836 }
837}