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(¶ms.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(¶ms.resource_type).arg(¶ms.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(¶ms.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(¶ms.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}