just_mcp_lib/
mcp_server.rs

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
41// Bridge snafu errors to MCP errors
42impl 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// Parameter structs for tools
53#[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// Response structs
77#[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            // Default justfile locations
131            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        // Parse arguments from JSON if provided
194        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        // Execute the recipe
201        let result = execute_recipe(
202            &justfile,
203            &params.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        // For now, just validate that it parsed correctly
256        // TODO: Add more comprehensive validation using validate_arguments for each recipe
257        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}