Skip to main content

pylon_plugin/builtin/
mcp.rs

1use crate::Plugin;
2use serde_json::Value;
3
4/// MCP tool definition — describes a tool an AI agent can call.
5#[derive(Debug, Clone)]
6pub struct McpTool {
7    pub name: String,
8    pub description: String,
9    pub input_schema: Value,
10}
11
12/// MCP resource — a readable data source.
13#[derive(Debug, Clone)]
14pub struct McpResource {
15    pub uri: String,
16    pub name: String,
17    pub description: String,
18    pub mime_type: String,
19}
20
21/// Result of calling an MCP tool.
22#[derive(Debug, Clone)]
23pub struct McpToolResult {
24    pub content: Vec<McpContent>,
25    pub is_error: bool,
26}
27
28#[derive(Debug, Clone)]
29pub struct McpContent {
30    pub content_type: String,
31    pub text: String,
32}
33
34/// MCP Server plugin. Exposes pylon as an MCP server for AI agents.
35/// Provides tools for CRUD operations, queries, actions, and schema inspection.
36pub struct McpPlugin {
37    app_name: String,
38    entities: Vec<String>,
39    actions: Vec<String>,
40    queries: Vec<String>,
41}
42
43impl McpPlugin {
44    pub fn new(app_name: &str) -> Self {
45        Self {
46            app_name: app_name.to_string(),
47            entities: vec![],
48            actions: vec![],
49            queries: vec![],
50        }
51    }
52
53    pub fn with_entities(mut self, entities: Vec<String>) -> Self {
54        self.entities = entities;
55        self
56    }
57
58    pub fn with_actions(mut self, actions: Vec<String>) -> Self {
59        self.actions = actions;
60        self
61    }
62
63    pub fn with_queries(mut self, queries: Vec<String>) -> Self {
64        self.queries = queries;
65        self
66    }
67
68    /// Generate the list of MCP tools this server exposes.
69    pub fn tools(&self) -> Vec<McpTool> {
70        let mut tools = vec![
71            McpTool {
72                name: "list_entities".into(),
73                description: format!("List all rows from an entity in the {} database", self.app_name),
74                input_schema: serde_json::json!({
75                    "type": "object",
76                    "properties": {
77                        "entity": {
78                            "type": "string",
79                            "description": format!("Entity name. Available: [{}]", self.entities.join(", ")),
80                        }
81                    },
82                    "required": ["entity"]
83                }),
84            },
85            McpTool {
86                name: "get_entity".into(),
87                description: "Get a single row by ID".into(),
88                input_schema: serde_json::json!({
89                    "type": "object",
90                    "properties": {
91                        "entity": { "type": "string", "description": "Entity name" },
92                        "id": { "type": "string", "description": "Row ID" }
93                    },
94                    "required": ["entity", "id"]
95                }),
96            },
97            McpTool {
98                name: "insert_entity".into(),
99                description: "Insert a new row into an entity".into(),
100                input_schema: serde_json::json!({
101                    "type": "object",
102                    "properties": {
103                        "entity": { "type": "string", "description": "Entity name" },
104                        "data": { "type": "object", "description": "Row data as key-value pairs" }
105                    },
106                    "required": ["entity", "data"]
107                }),
108            },
109            McpTool {
110                name: "update_entity".into(),
111                description: "Update an existing row".into(),
112                input_schema: serde_json::json!({
113                    "type": "object",
114                    "properties": {
115                        "entity": { "type": "string", "description": "Entity name" },
116                        "id": { "type": "string", "description": "Row ID" },
117                        "data": { "type": "object", "description": "Fields to update" }
118                    },
119                    "required": ["entity", "id", "data"]
120                }),
121            },
122            McpTool {
123                name: "delete_entity".into(),
124                description: "Delete a row by ID".into(),
125                input_schema: serde_json::json!({
126                    "type": "object",
127                    "properties": {
128                        "entity": { "type": "string", "description": "Entity name" },
129                        "id": { "type": "string", "description": "Row ID" }
130                    },
131                    "required": ["entity", "id"]
132                }),
133            },
134            McpTool {
135                name: "search".into(),
136                description: "Search across entities with a text query".into(),
137                input_schema: serde_json::json!({
138                    "type": "object",
139                    "properties": {
140                        "entity": { "type": "string", "description": "Entity to search in" },
141                        "query": { "type": "string", "description": "Search text" }
142                    },
143                    "required": ["entity", "query"]
144                }),
145            },
146            McpTool {
147                name: "inspect_schema".into(),
148                description: format!("Get the full schema of the {} app including entities, fields, queries, actions, and policies", self.app_name),
149                input_schema: serde_json::json!({
150                    "type": "object",
151                    "properties": {}
152                }),
153            },
154        ];
155
156        // Add action-specific tools.
157        for action in &self.actions {
158            tools.push(McpTool {
159                name: format!("action_{action}"),
160                description: format!("Execute the {action} action"),
161                input_schema: serde_json::json!({
162                    "type": "object",
163                    "properties": {
164                        "input": { "type": "object", "description": "Action input data" }
165                    },
166                    "required": ["input"]
167                }),
168            });
169        }
170
171        tools
172    }
173
174    /// Generate MCP resources (readable data sources).
175    pub fn resources(&self) -> Vec<McpResource> {
176        let mut resources = vec![McpResource {
177            uri: "pylon://schema".into(),
178            name: "App Schema".into(),
179            description: "The full app manifest/schema".into(),
180            mime_type: "application/json".into(),
181        }];
182
183        for entity in &self.entities {
184            resources.push(McpResource {
185                uri: format!("pylon://entities/{entity}"),
186                name: format!("{entity} data"),
187                description: format!("All rows in the {entity} entity"),
188                mime_type: "application/json".into(),
189            });
190        }
191
192        resources
193    }
194
195    /// Generate the MCP server manifest (for tool discovery).
196    pub fn server_info(&self) -> Value {
197        serde_json::json!({
198            "name": format!("{}-pylon", self.app_name),
199            "version": "0.1.0",
200            "description": format!("MCP server for {} powered by pylon", self.app_name),
201            "tools": self.tools().iter().map(|t| serde_json::json!({
202                "name": t.name,
203                "description": t.description,
204                "inputSchema": t.input_schema,
205            })).collect::<Vec<_>>(),
206            "resources": self.resources().iter().map(|r| serde_json::json!({
207                "uri": r.uri,
208                "name": r.name,
209                "description": r.description,
210                "mimeType": r.mime_type,
211            })).collect::<Vec<_>>(),
212        })
213    }
214}
215
216impl Plugin for McpPlugin {
217    fn name(&self) -> &str {
218        "mcp"
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn test_plugin() -> McpPlugin {
227        McpPlugin::new("test-app")
228            .with_entities(vec!["User".into(), "Todo".into()])
229            .with_actions(vec!["createTodo".into(), "toggleTodo".into()])
230            .with_queries(vec!["allTodos".into()])
231    }
232
233    #[test]
234    fn generates_tools() {
235        let plugin = test_plugin();
236        let tools = plugin.tools();
237        // 7 base tools + 2 action tools
238        assert_eq!(tools.len(), 9);
239        assert!(tools.iter().any(|t| t.name == "list_entities"));
240        assert!(tools.iter().any(|t| t.name == "action_createTodo"));
241        assert!(tools.iter().any(|t| t.name == "inspect_schema"));
242    }
243
244    #[test]
245    fn generates_resources() {
246        let plugin = test_plugin();
247        let resources = plugin.resources();
248        // 1 schema + 2 entity resources
249        assert_eq!(resources.len(), 3);
250        assert!(resources.iter().any(|r| r.uri == "pylon://schema"));
251        assert!(resources.iter().any(|r| r.uri == "pylon://entities/User"));
252    }
253
254    #[test]
255    fn server_info_is_valid_json() {
256        let plugin = test_plugin();
257        let info = plugin.server_info();
258        assert!(info.get("name").is_some());
259        assert!(info.get("tools").unwrap().as_array().unwrap().len() > 0);
260        assert!(info.get("resources").unwrap().as_array().unwrap().len() > 0);
261    }
262
263    #[test]
264    fn tool_schemas_have_required_fields() {
265        let plugin = test_plugin();
266        let tools = plugin.tools();
267        for tool in &tools {
268            assert!(!tool.name.is_empty());
269            assert!(!tool.description.is_empty());
270            assert!(tool.input_schema.is_object());
271        }
272    }
273
274    #[test]
275    fn entity_names_in_tool_description() {
276        let plugin = test_plugin();
277        let tools = plugin.tools();
278        let list_tool = tools.iter().find(|t| t.name == "list_entities").unwrap();
279        let schema_str = serde_json::to_string(&list_tool.input_schema).unwrap();
280        assert!(schema_str.contains("User"));
281        assert!(schema_str.contains("Todo"));
282    }
283
284    #[test]
285    fn empty_plugin() {
286        let plugin = McpPlugin::new("empty");
287        let tools = plugin.tools();
288        assert_eq!(tools.len(), 7); // base tools only
289        let resources = plugin.resources();
290        assert_eq!(resources.len(), 1); // schema only
291    }
292}