Skip to main content

cortex_runtime/compiler/
codegen_mcp.rs

1//! MCP tool definition generator.
2//!
3//! Generates MCP tool definitions scoped to a specific compiled site.
4
5use crate::compiler::models::*;
6
7/// Generate MCP tool definitions JSON from a compiled schema.
8pub fn generate_mcp(schema: &CompiledSchema) -> String {
9    let domain = &schema.domain;
10    let domain_prefix = domain.replace(['.', '-'], "_");
11
12    let mut tools: Vec<serde_json::Value> = Vec::new();
13
14    // Generate search tools for each collection model
15    for model in &schema.models {
16        if model.instance_count <= 1 {
17            continue;
18        }
19
20        let model_lower = model.name.to_lowercase();
21
22        // Search tool
23        let mut search_props = serde_json::Map::new();
24        search_props.insert(
25            "query".to_string(),
26            serde_json::json!({"type": "string", "description": format!("Search query for {}s", model.name)}),
27        );
28        search_props.insert(
29            "limit".to_string(),
30            serde_json::json!({"type": "integer", "description": "Max results", "default": 20}),
31        );
32
33        for field in &model.fields {
34            if let Some(_dim) = field.feature_dim {
35                match field.field_type {
36                    FieldType::Float | FieldType::Integer => {
37                        search_props.insert(
38                            format!("{}_min", field.name),
39                            serde_json::json!({
40                                "type": "number",
41                                "description": format!("Minimum {}", field.name)
42                            }),
43                        );
44                        search_props.insert(
45                            format!("{}_max", field.name),
46                            serde_json::json!({
47                                "type": "number",
48                                "description": format!("Maximum {}", field.name)
49                            }),
50                        );
51                    }
52                    _ => {}
53                }
54            }
55        }
56
57        tools.push(serde_json::json!({
58            "name": format!("{domain_prefix}_search_{model_lower}s"),
59            "description": format!("Search {}s on {domain}", model.name),
60            "inputSchema": {
61                "type": "object",
62                "properties": search_props,
63                "required": ["query"]
64            }
65        }));
66
67        // Get by ID tool
68        tools.push(serde_json::json!({
69            "name": format!("{domain_prefix}_get_{model_lower}"),
70            "description": format!("Get a specific {} by node ID from {domain}", model.name),
71            "inputSchema": {
72                "type": "object",
73                "properties": {
74                    "node_id": {
75                        "type": "integer",
76                        "description": format!("Node ID of the {}", model.name)
77                    }
78                },
79                "required": ["node_id"]
80            }
81        }));
82    }
83
84    // Generate action tools
85    for action in &schema.actions {
86        let mut props = serde_json::Map::new();
87
88        if action.is_instance_method {
89            props.insert(
90                "node_id".to_string(),
91                serde_json::json!({
92                    "type": "integer",
93                    "description": format!("Node ID of the {}", action.belongs_to)
94                }),
95            );
96        }
97
98        let mut required: Vec<String> = Vec::new();
99        if action.is_instance_method {
100            required.push("node_id".to_string());
101        }
102
103        for param in &action.params {
104            if param.name == "node_id" {
105                continue;
106            }
107            let type_str = match param.param_type {
108                FieldType::Integer => "integer",
109                FieldType::Float => "number",
110                FieldType::Bool => "boolean",
111                _ => "string",
112            };
113            let mut prop = serde_json::json!({
114                "type": type_str,
115                "description": format!("{} parameter", param.name)
116            });
117            if let Some(ref default) = param.default_value {
118                prop["default"] = serde_json::Value::String(default.clone());
119            }
120            props.insert(param.name.clone(), prop);
121            if param.required {
122                required.push(param.name.clone());
123            }
124        }
125
126        tools.push(serde_json::json!({
127            "name": format!("{domain_prefix}_{}", action.name),
128            "description": format!("{} on {domain}", action.name.replace('_', " ")),
129            "inputSchema": {
130                "type": "object",
131                "properties": props,
132                "required": required
133            }
134        }));
135    }
136
137    let output = serde_json::json!({ "tools": tools });
138    serde_json::to_string_pretty(&output).unwrap_or_default()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use chrono::Utc;
145
146    fn test_schema() -> CompiledSchema {
147        CompiledSchema {
148            domain: "shop.com".to_string(),
149            compiled_at: Utc::now(),
150            models: vec![DataModel {
151                name: "Product".to_string(),
152                schema_org_type: "Product".to_string(),
153                fields: vec![ModelField {
154                    name: "price".to_string(),
155                    field_type: FieldType::Float,
156                    source: FieldSource::JsonLd,
157                    confidence: 0.99,
158                    nullable: true,
159                    example_values: vec![],
160                    feature_dim: Some(48),
161                }],
162                instance_count: 50,
163                example_urls: vec![],
164                search_action: None,
165                list_url: None,
166            }],
167            actions: vec![CompiledAction {
168                name: "add_to_cart".to_string(),
169                belongs_to: "Product".to_string(),
170                is_instance_method: true,
171                http_method: "POST".to_string(),
172                endpoint_template: "/cart/add".to_string(),
173                params: vec![ActionParam {
174                    name: "quantity".to_string(),
175                    param_type: FieldType::Integer,
176                    required: false,
177                    default_value: Some("1".to_string()),
178                    source: "json_body".to_string(),
179                }],
180                requires_auth: false,
181                execution_path: "http".to_string(),
182                confidence: 0.9,
183            }],
184            relationships: vec![],
185            stats: SchemaStats {
186                total_models: 1,
187                total_fields: 1,
188                total_instances: 50,
189                avg_confidence: 0.99,
190            },
191        }
192    }
193
194    #[test]
195    fn test_generate_mcp_tools() {
196        let json = generate_mcp(&test_schema());
197        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
198
199        let tools = parsed["tools"].as_array().unwrap();
200        assert!(tools.len() >= 2, "should have search + action tools");
201
202        // Should have search tool
203        let search = tools
204            .iter()
205            .find(|t| t["name"].as_str().unwrap().contains("search"));
206        assert!(search.is_some(), "should have search tool");
207
208        // Should have add_to_cart tool
209        let cart = tools
210            .iter()
211            .find(|t| t["name"].as_str().unwrap().contains("add_to_cart"));
212        assert!(cart.is_some(), "should have add_to_cart tool");
213    }
214
215    #[test]
216    fn test_mcp_valid_json() {
217        let json = generate_mcp(&test_schema());
218        let result: Result<serde_json::Value, _> = serde_json::from_str(&json);
219        assert!(result.is_ok(), "output should be valid JSON");
220    }
221}