1use rmcp::schemars::{self, JsonSchema};
2use serde::{Deserialize, Serialize};
3use snafu::prelude::*;
4use std::collections::HashMap;
5use std::path::Path;
6
7use rmcp::{
8 handler::server::{ServerHandler, router::tool::ToolRouter, tool::Parameters},
9 model::{
10 CallToolResult, Content, ErrorCode, ErrorData as McpError, Implementation, ProtocolVersion,
11 ServerCapabilities, ServerInfo,
12 },
13 tool, tool_handler, tool_router,
14};
15
16use crate::executor::{ExecutionError, execute_recipe};
17use crate::parser::{ParserError, parse_justfile_str};
18use crate::{Justfile, Recipe};
19
20#[derive(Debug, Snafu)]
21pub enum McpServerError {
22 #[snafu(display("Parse error: {}", source))]
23 ParseFailed { source: ParserError },
24
25 #[snafu(display("Execution error: {}", source))]
26 ExecutionFailed { source: ExecutionError },
27
28 #[snafu(display("IO error: {}", source))]
29 IoError { source: std::io::Error },
30
31 #[snafu(display("Serialization error: {}", source))]
32 SerializationError { source: serde_json::Error },
33
34 #[snafu(display("Justfile not found at path: {}", path))]
35 JustfileNotFound { path: String },
36
37 #[snafu(display("Recipe '{}' not found", recipe_name))]
38 RecipeNotFound { recipe_name: String },
39}
40
41impl From<McpServerError> for McpError {
43 fn from(err: McpServerError) -> Self {
44 McpError {
45 code: ErrorCode(-1),
46 message: err.to_string().into(),
47 data: None,
48 }
49 }
50}
51
52#[derive(Debug, Deserialize, JsonSchema)]
54pub struct ListRecipesParams {
55 pub justfile_path: Option<String>,
56}
57
58#[derive(Debug, Deserialize, JsonSchema)]
59pub struct ExecuteRecipeParams {
60 pub recipe_name: String,
61 pub args: Option<String>,
62 pub justfile_path: Option<String>,
63}
64
65#[derive(Debug, Deserialize, JsonSchema)]
66pub struct GetRecipeInfoParams {
67 pub recipe_name: String,
68 pub justfile_path: Option<String>,
69}
70
71#[derive(Debug, Deserialize, JsonSchema)]
72pub struct ValidateJustfileParams {
73 pub justfile_path: Option<String>,
74}
75
76#[derive(Debug, Serialize, Deserialize)]
78pub struct RecipeInfo {
79 pub name: String,
80 pub parameters: Vec<ParameterInfo>,
81 pub documentation: Option<String>,
82 pub dependencies: Vec<String>,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86pub struct ParameterInfo {
87 pub name: String,
88 pub default_value: Option<String>,
89 pub required: bool,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93pub struct JustfileInfo {
94 pub path: String,
95 pub recipes: Vec<RecipeInfo>,
96 pub variables: HashMap<String, String>,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct ExecutionOutput {
101 pub recipe_name: String,
102 pub stdout: String,
103 pub stderr: String,
104 pub exit_code: i32,
105 pub duration_ms: u64,
106 pub success: bool,
107}
108
109#[derive(Clone)]
110pub struct JustMcpServer {
111 working_dir: std::path::PathBuf,
112 tool_router: ToolRouter<Self>,
113}
114
115impl JustMcpServer {
116 pub fn new(working_dir: impl AsRef<Path>) -> Self {
117 Self {
118 working_dir: working_dir.as_ref().to_path_buf(),
119 tool_router: Self::tool_router(),
120 }
121 }
122
123 fn load_justfile(
124 &self,
125 justfile_path: Option<&str>,
126 ) -> Result<(Justfile, std::path::PathBuf), McpServerError> {
127 let justfile_path = if let Some(path) = justfile_path {
128 self.working_dir.join(path)
129 } else {
130 let candidates = ["justfile", "Justfile", ".justfile"];
132 candidates
133 .iter()
134 .map(|name| self.working_dir.join(name))
135 .find(|path| path.exists())
136 .ok_or_else(|| McpServerError::JustfileNotFound {
137 path: self.working_dir.display().to_string(),
138 })?
139 };
140
141 let content = std::fs::read_to_string(&justfile_path).context(IoSnafu)?;
142
143 let justfile = parse_justfile_str(&content).context(ParseFailedSnafu)?;
144
145 Ok((justfile, justfile_path))
146 }
147
148 fn recipe_to_info(recipe: &Recipe) -> RecipeInfo {
149 RecipeInfo {
150 name: recipe.name.clone(),
151 parameters: recipe
152 .parameters
153 .iter()
154 .map(|p| ParameterInfo {
155 name: p.name.clone(),
156 default_value: p.default_value.clone(),
157 required: p.default_value.is_none(),
158 })
159 .collect(),
160 documentation: recipe.documentation.clone(),
161 dependencies: recipe.dependencies.clone(),
162 }
163 }
164}
165
166#[tool_router]
167impl JustMcpServer {
168 #[tool(description = "List all available recipes in the justfile")]
169 async fn list_recipes(
170 &self,
171 Parameters(params): Parameters<ListRecipesParams>,
172 ) -> Result<CallToolResult, McpError> {
173 let (justfile, path) = self.load_justfile(params.justfile_path.as_deref())?;
174
175 let info = JustfileInfo {
176 path: path.display().to_string(),
177 recipes: justfile.recipes.iter().map(Self::recipe_to_info).collect(),
178 variables: justfile.variables,
179 };
180
181 let content = serde_json::to_string_pretty(&info).context(SerializationSnafu)?;
182
183 Ok(CallToolResult::success(vec![Content::text(content)]))
184 }
185
186 #[tool(description = "Execute a specific recipe with optional arguments")]
187 async fn run_recipe(
188 &self,
189 Parameters(params): Parameters<ExecuteRecipeParams>,
190 ) -> Result<CallToolResult, McpError> {
191 let (justfile, _) = self.load_justfile(params.justfile_path.as_deref())?;
192
193 let parsed_args: Vec<String> = if let Some(args_str) = params.args {
195 serde_json::from_str(&args_str).context(SerializationSnafu)?
196 } else {
197 Vec::new()
198 };
199
200 let result = execute_recipe(
202 &justfile,
203 ¶ms.recipe_name,
204 &parsed_args,
205 &self.working_dir,
206 )
207 .context(ExecutionFailedSnafu)?;
208
209 let output = ExecutionOutput {
210 recipe_name: params.recipe_name,
211 stdout: result.stdout,
212 stderr: result.stderr,
213 exit_code: result.exit_code,
214 duration_ms: result.duration_ms,
215 success: result.exit_code == 0,
216 };
217
218 let content = serde_json::to_string_pretty(&output).context(SerializationSnafu)?;
219
220 if output.success {
221 Ok(CallToolResult::success(vec![Content::text(content)]))
222 } else {
223 Ok(CallToolResult::error(vec![Content::text(content)]))
224 }
225 }
226
227 #[tool(description = "Get detailed information about a specific recipe")]
228 async fn get_recipe_info(
229 &self,
230 Parameters(params): Parameters<GetRecipeInfoParams>,
231 ) -> Result<CallToolResult, McpError> {
232 let (justfile, _) = self.load_justfile(params.justfile_path.as_deref())?;
233
234 let recipe = justfile
235 .recipes
236 .iter()
237 .find(|r| r.name == params.recipe_name)
238 .ok_or_else(|| McpServerError::RecipeNotFound {
239 recipe_name: params.recipe_name.clone(),
240 })?;
241
242 let info = Self::recipe_to_info(recipe);
243 let content = serde_json::to_string_pretty(&info).context(SerializationSnafu)?;
244
245 Ok(CallToolResult::success(vec![Content::text(content)]))
246 }
247
248 #[tool(description = "Validate the justfile for syntax and semantic errors")]
249 async fn validate_justfile(
250 &self,
251 Parameters(params): Parameters<ValidateJustfileParams>,
252 ) -> Result<CallToolResult, McpError> {
253 let (justfile, path) = self.load_justfile(params.justfile_path.as_deref())?;
254
255 let is_valid = true;
258 let message = format!(
259 "Justfile parsed successfully with {} recipes",
260 justfile.recipes.len()
261 );
262
263 let result = serde_json::json!({
264 "path": path.display().to_string(),
265 "is_valid": is_valid,
266 "message": message,
267 "recipe_count": justfile.recipes.len(),
268 "variable_count": justfile.variables.len(),
269 });
270
271 let content = serde_json::to_string_pretty(&result).context(SerializationSnafu)?;
272
273 Ok(CallToolResult::success(vec![Content::text(content)]))
274 }
275}
276
277#[tool_handler]
278impl ServerHandler for JustMcpServer {
279 fn get_info(&self) -> ServerInfo {
280 ServerInfo {
281 protocol_version: ProtocolVersion::V_2024_11_05,
282 server_info: Implementation::from_build_env(),
283 instructions: Some("MCP server for Justfile integration. Provides tools to list, execute, inspect, and validate Justfile recipes.".into()),
284 capabilities: ServerCapabilities::builder()
285 .enable_tools()
286 .build(),
287 }
288 }
289}