gestura_core/tools/
schemas.rs

1//! Provider-specific tool schemas (OpenAI / Anthropic / Gemini)
2//!
3//! Gestura's pipeline keeps a text prompt format for portability, but some LLM
4//! providers require tool definitions to be passed **out-of-band** (as JSON
5//! schema) to enable structured tool calls.
6//!
7//! This module converts Gestura's built-in tool inventory into provider-specific
8//! schemas.
9//!
10//! Built-in tool schemas are owned by the `gestura-core-tools` domain crate; we
11//! re-export them here to preserve stable public paths.
12
13pub use gestura_core_tools::schemas::{
14    ProviderToolSchemas, build_provider_tool_schemas, normalize_openai_parameters_schema,
15};
16
17/// Build provider tool schemas from dynamically-discovered MCP tools.
18///
19/// Each MCP `Tool` already carries a JSON Schema `input_schema`, so we wrap it
20/// in the provider-specific envelope. The tool name is namespaced as
21/// `mcp__<server>__<tool>` so the pipeline can route calls back to the correct
22/// MCP server.
23pub fn build_mcp_tool_schemas(
24    server_tools: &[(String, Vec<crate::mcp::types::Tool>)],
25) -> ProviderToolSchemas {
26    let mut out = ProviderToolSchemas::default();
27
28    for (server_name, tools) in server_tools {
29        for tool in tools {
30            let namespaced = format!("mcp__{}__{}", server_name, tool.name);
31            let description = tool
32                .description
33                .clone()
34                .unwrap_or_else(|| format!("MCP tool {}/{}", server_name, tool.name));
35
36            let openai = serde_json::json!({
37                "type": "function",
38                "function": {
39                    "name": namespaced,
40                    "description": description,
41                    "parameters": normalize_openai_parameters_schema(tool.input_schema.clone())
42                }
43            });
44            let openai_responses = serde_json::json!({
45                "type": "function",
46                "name": namespaced,
47                "description": description,
48                "parameters": normalize_openai_parameters_schema(tool.input_schema.clone())
49            });
50            let anthropic = serde_json::json!({
51                "name": namespaced,
52                "description": description,
53                "input_schema": tool.input_schema
54            });
55            let gemini = serde_json::json!({
56                "name": namespaced,
57                "description": description,
58                "parameters": tool.input_schema
59            });
60
61            out.openai.push(openai);
62            out.openai_responses.push(openai_responses);
63            out.anthropic.push(anthropic);
64            out.gemini.push(gemini);
65        }
66    }
67
68    out
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::mcp::types::Tool;
75    use crate::tools::registry::find_tool;
76
77    #[test]
78    fn builds_shell_schema_for_all_providers() {
79        let shell = find_tool("shell").unwrap();
80        let schemas = build_provider_tool_schemas(&[shell]);
81        assert_eq!(schemas.openai.len(), 1);
82        assert_eq!(schemas.openai_responses.len(), 1);
83        assert_eq!(schemas.anthropic.len(), 1);
84        assert_eq!(schemas.gemini.len(), 1);
85
86        // OpenAI format: {type:"function", function:{name, description, parameters}}
87        assert_eq!(schemas.openai[0]["function"]["name"], "shell");
88        assert!(
89            schemas.openai[0]["function"]["parameters"]["required"]
90                .as_array()
91                .unwrap()
92                .iter()
93                .any(|v| v == "command")
94        );
95        assert_eq!(
96            schemas.openai[0]["function"]["parameters"]["properties"]["allow_long_running"]["type"],
97            "boolean"
98        );
99        assert_eq!(schemas.openai_responses[0]["name"], "shell");
100        assert_eq!(
101            schemas.openai_responses[0]["parameters"]["properties"]["allow_long_running"]["type"],
102            "boolean"
103        );
104
105        // Gemini format: {name, description, parameters}
106        assert_eq!(schemas.gemini[0]["name"], "shell");
107        assert!(
108            schemas.gemini[0]["parameters"]["required"]
109                .as_array()
110                .unwrap()
111                .iter()
112                .any(|v| v == "command")
113        );
114        assert_eq!(
115            schemas.gemini[0]["parameters"]["properties"]["stall_timeout_secs"]["type"],
116            "integer"
117        );
118    }
119
120    #[test]
121    fn mcp_openai_schemas_strip_top_level_combinators() {
122        let schemas = build_mcp_tool_schemas(&[(
123            "demo".to_string(),
124            vec![Tool {
125                name: "inspect".to_string(),
126                description: Some("Inspect demo state".to_string()),
127                input_schema: serde_json::json!({
128                    "type": "object",
129                    "properties": {
130                        "path": {"type": "string"}
131                    },
132                    "oneOf": [
133                        {"required": ["path"]}
134                    ],
135                    "additionalProperties": false
136                }),
137                annotations: None,
138            }],
139        )]);
140
141        assert!(
142            schemas.openai[0]["function"]["parameters"]
143                .get("oneOf")
144                .is_none()
145        );
146        assert!(
147            schemas.openai_responses[0]["parameters"]
148                .get("oneOf")
149                .is_none()
150        );
151        assert_eq!(
152            schemas.openai[0]["function"]["parameters"]["type"],
153            serde_json::json!("object")
154        );
155        assert!(schemas.anthropic[0]["input_schema"]["oneOf"].is_array());
156    }
157}