Skip to main content

incurs_codemode/
connector.rs

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/// Replay behavior for a connector call.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ReplayPolicy {
14    /// Record the result and replay it without repeating the call.
15    #[default]
16    Log,
17    /// Re-execute the call during every replay pass.
18    Reexecute,
19}
20
21/// Runtime annotations for one connector method.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ToolAnnotations {
24    /// Whether the method is declared read-only.
25    pub read_only: Option<bool>,
26    /// Whether the method may perform destructive updates.
27    pub destructive: Option<bool>,
28    /// Whether repeated calls have no additional effect.
29    pub idempotent: Option<bool>,
30    /// Whether the method may interact with external entities.
31    pub open_world: Option<bool>,
32}
33
34/// Resolved approval and deterministic replay policy for one method.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ToolPolicy {
37    /// Whether the call must pause for approval.
38    pub requires_approval: bool,
39    /// How the result participates in replay.
40    pub replay: ReplayPolicy,
41}
42
43/// Trust boundary used while resolving tool policy.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ToolOrigin {
46    /// Metadata declared by the local incurs command graph.
47    Local,
48    /// Metadata received from a remote MCP server.
49    RemoteMcp,
50    /// Metadata inferred from a remote OpenAPI document.
51    OpenApi,
52}
53
54/// Resolves approval and replay behavior from tool metadata.
55pub trait ToolPolicyResolver: Send + Sync {
56    /// Resolves one method's effective policy.
57    fn resolve(&self, origin: ToolOrigin, annotations: &ToolAnnotations) -> ToolPolicy;
58}
59
60/// Conservative default policy resolver.
61#[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/// One model-facing connector usage example.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ConnectorExample {
84    /// Example invocation.
85    pub command: String,
86    /// Explanation of the example.
87    pub description: Option<String>,
88}
89
90/// Metadata for one connector method.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ConnectorTool {
93    /// Method name within its connector namespace.
94    pub name: String,
95    /// Human-readable description.
96    pub description: Option<String>,
97    /// JSON Schema for arguments.
98    pub input_schema: Value,
99    /// JSON Schema for successful output.
100    pub output_schema: Option<Value>,
101    /// Tool-specific model instructions.
102    pub instructions: Option<String>,
103    /// Usage examples.
104    pub examples: Vec<ConnectorExample>,
105    /// Source behavioral annotations.
106    pub annotations: ToolAnnotations,
107    /// Resolved approval and replay behavior.
108    pub policy: ToolPolicy,
109}
110
111/// Model-visible description of a connector.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ConnectorDescription {
114    /// JavaScript namespace used in sandbox code.
115    pub name: String,
116    /// Connector-level model guidance.
117    pub instructions: Option<String>,
118    /// Methods exposed by the connector.
119    pub tools: Vec<ConnectorTool>,
120}
121
122/// Stable execution context passed to connector calls and compensation.
123#[derive(Clone)]
124pub struct ToolContext {
125    /// Durable execution identifier, stable across replay passes.
126    pub execution_id: String,
127    /// Execution-scoped cancellation and ordered event delivery.
128    pub control: ToolCallControl,
129    /// Transport request metadata inherited by connector calls.
130    pub request: Option<RequestContext>,
131}
132
133/// A transport-neutral source of sandbox-callable tools.
134#[async_trait]
135pub trait Connector: Send + Sync {
136    /// Returns connector metadata and schemas.
137    async fn describe(&self) -> Result<ConnectorDescription, String>;
138
139    /// Executes one connector method.
140    async fn execute(
141        &self,
142        method: &str,
143        arguments: Value,
144        context: &ToolContext,
145    ) -> Result<Value, String>;
146
147    /// Compensates a previously applied connector action.
148    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    /// Releases resources scoped to one sandbox pass.
159    async fn pass_ended(&self, _execution_id: &str, _status: &str) {}
160
161    /// Releases resources scoped to a complete execution.
162    async fn execution_ended(&self, _execution_id: &str, _status: &str) {}
163}
164
165/// Adapts an incurs command graph into one Code Mode connector.
166#[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    /// Creates a connector from a resolved incurs tool catalog.
177    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    /// Overrides the JavaScript namespace exposed to sandbox programs.
189    pub fn with_name(mut self, name: impl Into<String>) -> Self {
190        self.name = name.into();
191        self
192    }
193
194    /// Adds connector-level instructions to model-facing documentation.
195    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
196        self.instructions = Some(instructions.into());
197        self
198    }
199
200    /// Sets the call options shared by every incurs command invocation.
201    pub fn with_call_options(mut self, options: ToolCallOptions) -> Self {
202        self.options = options;
203        self
204    }
205
206    /// Overrides policy resolution for local incurs tools.
207    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
309/// Validates and normalizes a namespace from a CLI or connector name.
310pub 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/// MCP tool metadata used by the transport-neutral MCP adapter.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct McpTool {
331    /// Original MCP tool name.
332    pub name: String,
333    /// Human-readable description.
334    pub description: Option<String>,
335    /// MCP input schema.
336    pub input_schema: Value,
337    /// Optional MCP output schema.
338    pub output_schema: Option<Value>,
339    /// MCP behavioral annotations.
340    pub annotations: Option<incurs::command::McpAnnotations>,
341}
342
343/// Minimal MCP client contract required by Code Mode.
344#[async_trait]
345pub trait McpClient: Send + Sync {
346    /// Lists tools from the remote MCP server.
347    async fn list_tools(&self) -> Result<Vec<McpTool>, String>;
348
349    /// Calls one remote MCP tool with object arguments.
350    async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, String>;
351}
352
353/// Exposes a remote MCP connection as a Code Mode connector.
354pub 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    /// Creates an MCP-backed connector.
364    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    /// Adds server-level model instructions.
375    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
376        self.instructions = Some(instructions.into());
377        self
378    }
379
380    /// Overrides policy resolution for remote MCP tools.
381    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/// Authenticated request derived from an OpenAPI operation.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct OpenApiRequest {
470    /// Path or URL with path parameters substituted.
471    pub path: String,
472    /// HTTP method.
473    pub method: String,
474    /// Query parameters.
475    pub parameters: BTreeMap<String, Value>,
476    /// Optional request body.
477    pub body: Option<Value>,
478    /// Request headers derived from header parameters.
479    pub headers: BTreeMap<String, String>,
480}
481
482/// Host operations required by the OpenAPI connector.
483#[async_trait]
484pub trait OpenApiClient: Send + Sync {
485    /// Returns the OpenAPI document.
486    async fn specification(&self) -> Result<Value, String>;
487
488    /// Executes an authenticated request.
489    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
502/// Derives typed Code Mode tools from an OpenAPI document.
503pub 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    /// Creates an OpenAPI-backed connector.
513    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    /// Adds API-level model instructions.
524    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
525        self.instructions = Some(instructions.into());
526        self
527    }
528
529    /// Overrides policy resolution for OpenAPI tools.
530    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}