Skip to main content

rapina_mcp/
lib.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use rmcp::{
6    ServerHandler,
7    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
8    model::{CallToolResult, Content, ErrorData as McpError, ServerCapabilities, ServerInfo},
9    schemars, tool, tool_handler, tool_router,
10};
11
12#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
13pub struct NewProjectParams {
14    #[schemars(description = "Name of the new Rapina project")]
15    pub name: String,
16    #[schemars(description = "Directory where the project will be created (defaults to current directory)")]
17    pub path: Option<String>,
18}
19
20#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
21pub struct AddResourceParams {
22    #[schemars(description = "Type of resource to add (e.g. handler, model, migration)")]
23    pub resource_type: String,
24    #[schemars(description = "Name of the resource")]
25    pub name: String,
26    #[schemars(description = "Path to the Rapina project root")]
27    pub project_path: Option<String>,
28}
29
30#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
31pub struct ProjectPathParams {
32    #[schemars(description = "Path to the Rapina project root")]
33    pub project_path: Option<String>,
34}
35
36#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
37pub struct MigrateParams {
38    #[schemars(description = "Migration subcommand (e.g. run, rollback, status)")]
39    pub action: String,
40    #[schemars(description = "Path to the Rapina project root")]
41    pub project_path: Option<String>,
42}
43
44#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
45pub struct ExplainParams {
46    #[schemars(description = "Path to the Rapina project root")]
47    pub project_path: String,
48}
49
50#[derive(Debug, Clone)]
51pub struct RapinaMcp {
52    tool_router: ToolRouter<Self>,
53}
54
55#[tool_router]
56impl RapinaMcp {
57    pub fn new() -> Self {
58        Self {
59            tool_router: Self::tool_router(),
60        }
61    }
62
63    #[tool(description = "Create a new Rapina project with the standard directory structure")]
64    fn rapina_new(
65        &self,
66        Parameters(params): Parameters<NewProjectParams>,
67    ) -> Result<CallToolResult, McpError> {
68        let mut cmd = Command::new("rapina");
69        cmd.arg("new").arg(&params.name);
70        if let Some(ref path) = params.path {
71            cmd.current_dir(path);
72        }
73        run_command(cmd)
74    }
75
76    #[tool(description = "Add a resource (handler, model, migration) to an existing Rapina project")]
77    fn rapina_add(
78        &self,
79        Parameters(params): Parameters<AddResourceParams>,
80    ) -> Result<CallToolResult, McpError> {
81        let mut cmd = Command::new("rapina");
82        cmd.arg("add").arg(&params.resource_type).arg(&params.name);
83        if let Some(ref path) = params.project_path {
84            cmd.current_dir(path);
85        }
86        run_command(cmd)
87    }
88
89    #[tool(description = "List all routes defined in a Rapina project")]
90    fn rapina_routes(
91        &self,
92        Parameters(params): Parameters<ProjectPathParams>,
93    ) -> Result<CallToolResult, McpError> {
94        let mut cmd = Command::new("rapina");
95        cmd.arg("routes");
96        if let Some(ref path) = params.project_path {
97            cmd.current_dir(path);
98        }
99        run_command(cmd)
100    }
101
102    #[tool(description = "Run rapina doctor to diagnose common issues in a Rapina project (missing config, auth misconfiguration, dependency problems)")]
103    fn rapina_doctor(
104        &self,
105        Parameters(params): Parameters<ProjectPathParams>,
106    ) -> Result<CallToolResult, McpError> {
107        let mut cmd = Command::new("rapina");
108        cmd.arg("doctor");
109        if let Some(ref path) = params.project_path {
110            cmd.current_dir(path);
111        }
112        run_command(cmd)
113    }
114
115    #[tool(description = "Generate the OpenAPI specification for a Rapina project")]
116    fn rapina_openapi(
117        &self,
118        Parameters(params): Parameters<ProjectPathParams>,
119    ) -> Result<CallToolResult, McpError> {
120        let mut cmd = Command::new("rapina");
121        cmd.arg("openapi");
122        if let Some(ref path) = params.project_path {
123            cmd.current_dir(path);
124        }
125        run_command(cmd)
126    }
127
128    #[tool(description = "Run code generation for a Rapina project")]
129    fn rapina_codegen(
130        &self,
131        Parameters(params): Parameters<ProjectPathParams>,
132    ) -> Result<CallToolResult, McpError> {
133        let mut cmd = Command::new("rapina");
134        cmd.arg("codegen");
135        if let Some(ref path) = params.project_path {
136            cmd.current_dir(path);
137        }
138        run_command(cmd)
139    }
140
141    #[tool(description = "Run database migrations for a Rapina project")]
142    fn rapina_migrate(
143        &self,
144        Parameters(params): Parameters<MigrateParams>,
145    ) -> Result<CallToolResult, McpError> {
146        let mut cmd = Command::new("rapina");
147        cmd.arg("migrate").arg(&params.action);
148        if let Some(ref path) = params.project_path {
149            cmd.current_dir(path);
150        }
151        run_command(cmd)
152    }
153
154    #[tool(description = "Run tests in a Rapina project")]
155    fn rapina_test(
156        &self,
157        Parameters(params): Parameters<ProjectPathParams>,
158    ) -> Result<CallToolResult, McpError> {
159        let mut cmd = Command::new("rapina");
160        cmd.arg("test");
161        if let Some(ref path) = params.project_path {
162            cmd.current_dir(path);
163        }
164        run_command(cmd)
165    }
166
167    #[tool(description = "Introspect a Rapina project and return a structured summary of its architecture: modules, routes, middleware, auth configuration, database setup, and dependencies")]
168    fn rapina_explain(
169        &self,
170        Parameters(params): Parameters<ExplainParams>,
171    ) -> Result<CallToolResult, McpError> {
172        let root = PathBuf::from(&params.project_path);
173        if !root.exists() {
174            return Err(McpError::invalid_params(
175                format!("Project path does not exist: {}", params.project_path),
176                None,
177            ));
178        }
179
180        let mut report = String::new();
181        report.push_str(&format!("# Rapina Project: {}\n\n", params.project_path));
182
183        let cargo_path = root.join("Cargo.toml");
184        if cargo_path.exists() {
185            if let Ok(content) = fs::read_to_string(&cargo_path) {
186                report.push_str("## Cargo.toml\n\n");
187                report.push_str(&extract_cargo_summary(&content));
188                report.push('\n');
189            }
190        } else {
191            report.push_str("**Warning:** No Cargo.toml found. Is this a Rapina project?\n\n");
192        }
193
194        let src_dir = root.join("src");
195        if src_dir.exists() {
196            report.push_str("## Project Structure\n\n");
197            report.push_str(&walk_source_tree(&src_dir, 0));
198            report.push('\n');
199        }
200
201        report.push_str("## Modules Detected\n\n");
202        let modules = detect_modules(&src_dir);
203        if modules.is_empty() {
204            report.push_str("No feature modules detected.\n\n");
205        } else {
206            for module in &modules {
207                report.push_str(&format!("- **{}**\n", module));
208            }
209            report.push('\n');
210        }
211
212        let middleware_dir = src_dir.join("middleware");
213        if middleware_dir.exists() {
214            report.push_str("## Middleware\n\n");
215            if let Ok(entries) = fs::read_dir(&middleware_dir) {
216                for entry in entries.flatten() {
217                    let name = entry.file_name().to_string_lossy().into_owned();
218                    if name.ends_with(".rs") && name != "mod.rs" {
219                        report.push_str(&format!("- {}\n", name.trim_end_matches(".rs")));
220                    }
221                }
222            }
223            report.push('\n');
224        }
225
226        let migrations_dir = root.join("migrations");
227        if migrations_dir.exists() {
228            report.push_str("## Migrations\n\n");
229            if let Ok(entries) = fs::read_dir(&migrations_dir) {
230                let mut files: Vec<String> = entries
231                    .flatten()
232                    .map(|e| e.file_name().to_string_lossy().into_owned())
233                    .collect();
234                files.sort();
235                for f in &files {
236                    report.push_str(&format!("- {}\n", f));
237                }
238            }
239            report.push('\n');
240        }
241
242        report.push_str("## Configuration Files\n\n");
243        for name in ["rapina.toml", "Rapina.toml", ".env", ".env.example", "config.toml"] {
244            if root.join(name).exists() {
245                report.push_str(&format!("- {}\n", name));
246            }
247        }
248        report.push('\n');
249
250        Ok(CallToolResult::success(vec![Content::text(report)]))
251    }
252}
253
254#[tool_handler]
255impl ServerHandler for RapinaMcp {
256    fn get_info(&self) -> ServerInfo {
257        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
258            .with_server_info(rmcp::model::Implementation::from_build_env())
259            .with_instructions(
260                "MCP server for the Rapina web framework. \
261                 Provides tools to scaffold projects, inspect routes, \
262                 run diagnostics, generate code, and introspect Rapina applications."
263                    .to_string(),
264            )
265    }
266}
267
268fn run_command(mut cmd: Command) -> Result<CallToolResult, McpError> {
269    let output = cmd.output().map_err(|e| {
270        McpError::internal_error(format!("Failed to execute rapina CLI: {e}"), None)
271    })?;
272
273    let stdout = String::from_utf8_lossy(&output.stdout);
274    let stderr = String::from_utf8_lossy(&output.stderr);
275
276    if output.status.success() {
277        let mut text = stdout.into_owned();
278        if !stderr.is_empty() {
279            text.push_str("\n--- stderr ---\n");
280            text.push_str(&stderr);
281        }
282        Ok(CallToolResult::success(vec![Content::text(text)]))
283    } else {
284        let mut text = String::new();
285        if !stdout.is_empty() {
286            text.push_str(&stdout);
287            text.push('\n');
288        }
289        text.push_str(&stderr);
290        Ok(CallToolResult::error(vec![Content::text(text)]))
291    }
292}
293
294fn extract_cargo_summary(content: &str) -> String {
295    let mut summary = String::new();
296    if let Ok(doc) = content.parse::<toml::Table>() {
297        if let Some(package) = doc.get("package").and_then(|v| v.as_table()) {
298            if let Some(name) = package.get("name").and_then(|v| v.as_str()) {
299                summary.push_str(&format!("- **name:** {}\n", name));
300            }
301            if let Some(version) = package.get("version").and_then(|v| v.as_str()) {
302                summary.push_str(&format!("- **version:** {}\n", version));
303            }
304            if let Some(edition) = package.get("edition").and_then(|v| v.as_str()) {
305                summary.push_str(&format!("- **edition:** {}\n", edition));
306            }
307        }
308        if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_table()) {
309            let rapina_deps: Vec<&str> = deps
310                .keys()
311                .filter(|k| k.starts_with("rapina"))
312                .map(|s| s.as_str())
313                .collect();
314            if !rapina_deps.is_empty() {
315                summary.push_str(&format!("- **rapina deps:** {}\n", rapina_deps.join(", ")));
316            }
317            summary.push_str(&format!("- **total dependencies:** {}\n", deps.len()));
318        }
319    }
320    summary
321}
322
323fn walk_source_tree(dir: &Path, depth: usize) -> String {
324    let mut output = String::new();
325    let indent = "  ".repeat(depth);
326
327    let Ok(entries) = fs::read_dir(dir) else {
328        return output;
329    };
330
331    let mut entries: Vec<_> = entries.flatten().collect();
332    entries.sort_by_key(|e| e.file_name());
333
334    for entry in entries {
335        let name = entry.file_name().to_string_lossy().into_owned();
336        let path = entry.path();
337        if path.is_dir() {
338            output.push_str(&format!("{}- **{}/**\n", indent, name));
339            output.push_str(&walk_source_tree(&path, depth + 1));
340        } else if name.ends_with(".rs") {
341            output.push_str(&format!("{}- {}\n", indent, name));
342        }
343    }
344
345    output
346}
347
348fn detect_modules(src_dir: &Path) -> Vec<String> {
349    let mut modules = Vec::new();
350    let Ok(entries) = fs::read_dir(src_dir) else {
351        return modules;
352    };
353    for entry in entries.flatten() {
354        if entry.path().is_dir() {
355            let name = entry.file_name().to_string_lossy().into_owned();
356            if !matches!(name.as_str(), "middleware" | "config" | "common" | "utils") {
357                modules.push(name);
358            }
359        }
360    }
361    modules.sort();
362    modules
363}