Skip to main content

rusty_commit/commands/
mcp.rs

1use anyhow::Result;
2use colored::Colorize;
3use rmcp::{
4    handler::server::ServerHandler,
5    model::*,
6    service::{RequestContext, RoleServer},
7    ErrorData as McpError, ServiceExt,
8};
9use tokio::io::{stdin, stdout};
10
11use crate::cli::McpCommand;
12use crate::config::Config;
13use crate::git;
14
15/// Execute MCP command from CLI
16pub async fn execute(cmd: McpCommand) -> Result<()> {
17    match cmd.action {
18        crate::cli::McpAction::Server { port: _ } => {
19            anyhow::bail!(
20                "TCP server mode is not yet implemented.\n\
21                 Use 'rco mcp stdio' for MCP connections (Cursor, Claude Desktop, etc.).\n\
22                 TCP server support is planned for a future release."
23            );
24        }
25        crate::cli::McpAction::Stdio => start_stdio_server().await,
26    }
27}
28
29/// Start MCP server over stdio using RMCP SDK
30async fn start_stdio_server() -> Result<()> {
31    eprintln!(
32        "{}",
33        "šŸš€ Starting Rusty Commit MCP Server over STDIO"
34            .green()
35            .bold()
36    );
37    eprintln!(
38        "{}",
39        "šŸ“” Ready for MCP client connections (Cursor, Claude Desktop, etc.)".cyan()
40    );
41
42    let server = RustyCommitMcpServer::new();
43    let transport = (stdin(), stdout());
44
45    // Start the server
46    let service = server.serve(transport).await?;
47
48    // Wait for completion
49    service.waiting().await?;
50
51    Ok(())
52}
53
54/// Rusty Commit MCP Server implementation
55#[derive(Clone)]
56struct RustyCommitMcpServer;
57
58impl RustyCommitMcpServer {
59    fn new() -> Self {
60        Self
61    }
62}
63
64impl ServerHandler for RustyCommitMcpServer {
65    fn get_info(&self) -> ServerInfo {
66        ServerInfo {
67            protocol_version: ProtocolVersion::V_2024_11_05,
68            capabilities: ServerCapabilities::default(),
69            server_info: Implementation {
70                name: "rustycommit".to_string(),
71                version: env!("CARGO_PKG_VERSION").to_string(),
72                icons: None,
73                title: None,
74                website_url: None,
75            },
76            instructions: Some("Rusty Commit MCP Server - Generate AI-powered commit messages for your Git repositories.".to_string()),
77        }
78    }
79
80    async fn list_tools(
81        &self,
82        _request: Option<PaginatedRequestParam>,
83        _context: RequestContext<RoleServer>,
84    ) -> Result<ListToolsResult, McpError> {
85        let tools = vec![
86            Tool {
87                name: "generate_commit_message".into(),
88                description: Some(
89                    "Generate AI-powered commit message for staged git changes using Rusty Commit"
90                        .into(),
91                ),
92                input_schema: std::sync::Arc::new(
93                    serde_json::json!({
94                        "type": "object",
95                        "properties": {
96                            "context": {
97                                "type": "string",
98                                "description": "Additional context for the commit message"
99                            },
100                            "full_gitmoji": {
101                                "type": "boolean",
102                                "description": "Use full GitMoji specification",
103                                "default": false
104                            },
105                            "commit_type": {
106                                "type": "string",
107                                "description": "Commit format type (conventional, gitmoji)",
108                                "enum": ["conventional", "gitmoji"],
109                                "default": "conventional"
110                            }
111                        },
112                        "required": []
113                    })
114                    .as_object()
115                    .unwrap()
116                    .clone(),
117                ),
118                output_schema: None,
119                annotations: None,
120                icons: None,
121                title: None,
122                meta: None,
123            },
124            Tool {
125                name: "show_commit_prompt".into(),
126                description: Some(
127                    "Show the prompt that would be sent to AI for commit message generation".into(),
128                ),
129                input_schema: std::sync::Arc::new(
130                    serde_json::json!({
131                        "type": "object",
132                        "properties": {
133                            "context": {
134                                "type": "string",
135                                "description": "Additional context for the commit message"
136                            },
137                            "full_gitmoji": {
138                                "type": "boolean",
139                                "description": "Use full GitMoji specification",
140                                "default": false
141                            }
142                        },
143                        "required": []
144                    })
145                    .as_object()
146                    .unwrap()
147                    .clone(),
148                ),
149                output_schema: None,
150                annotations: None,
151                icons: None,
152                title: None,
153                meta: None,
154            },
155        ];
156
157        Ok(ListToolsResult {
158            tools,
159            next_cursor: None,
160            meta: None,
161        })
162    }
163
164    async fn call_tool(
165        &self,
166        request: CallToolRequestParam,
167        _context: RequestContext<RoleServer>,
168    ) -> Result<CallToolResult, McpError> {
169        match request.name.as_ref() {
170            "generate_commit_message" => generate_commit_message_mcp(&request.arguments).await,
171            "show_commit_prompt" => show_commit_prompt_mcp(&request.arguments).await,
172            _ => Ok(CallToolResult::error(vec![Content::text(format!(
173                "Unknown tool: {}",
174                request.name
175            ))])),
176        }
177    }
178}
179
180/// Generate commit message via MCP
181async fn generate_commit_message_mcp(
182    arguments: &Option<serde_json::Map<String, serde_json::Value>>,
183) -> Result<CallToolResult, McpError> {
184    // Ensure we're in a git repository
185    if let Err(e) = git::assert_git_repo() {
186        return Ok(CallToolResult::error(vec![Content::text(format!(
187            "āŒ Error: Not a git repository: {}",
188            e
189        ))]));
190    }
191
192    // Load configuration
193    let mut config = match Config::load() {
194        Ok(c) => c,
195        Err(e) => {
196            return Ok(CallToolResult::error(vec![Content::text(format!(
197                "āŒ Configuration error: {}",
198                e
199            ))]));
200        }
201    };
202
203    // Apply commitlint rules (log warnings but don't fail)
204    if let Err(e) = config.load_with_commitlint() {
205        tracing::warn!("Failed to load commitlint config: {}", e);
206    }
207    if let Err(e) = config.apply_commitlint_rules() {
208        tracing::warn!("Failed to apply commitlint rules: {}", e);
209    }
210
211    // Get staged diff
212    let diff = match git::get_staged_diff() {
213        Ok(d) => d,
214        Err(e) => {
215            return Ok(CallToolResult::error(vec![Content::text(format!(
216                "āŒ Git error: {}",
217                e
218            ))]));
219        }
220    };
221
222    if diff.is_empty() {
223        return Ok(CallToolResult::success(vec![Content::text(
224            "āš ļø  No staged changes found. Please stage your changes with 'git add' first.",
225        )]));
226    }
227
228    // Extract arguments
229    let args = arguments
230        .as_ref()
231        .map(|map| serde_json::Value::Object(map.clone()))
232        .unwrap_or(serde_json::json!({}));
233    let context = args["context"].as_str();
234    let full_gitmoji = args["full_gitmoji"].as_bool().unwrap_or(false);
235
236    // Override commit type if specified
237    if let Some(commit_type) = args["commit_type"].as_str() {
238        config.commit_type = Some(commit_type.to_string());
239    }
240
241    // Generate commit message
242    match generate_commit_message_internal(&config, &diff, context, full_gitmoji).await {
243        Ok(message) => {
244            let provider_name = config.ai_provider.as_deref().unwrap_or("openai");
245            let model_name = config.model.as_deref().unwrap_or("default");
246
247            Ok(CallToolResult::success(vec![Content::text(format!(
248                "šŸ¤– **Generated Commit Message:**\n\n```\n{}\n```\n\n**Details:**\n- Provider: {}\n- Model: {}\n- Generated by: Rusty Commit v{}\n\nšŸ’” You can now copy this message and use it in your commit.",
249                message,
250                provider_name,
251                model_name,
252                env!("CARGO_PKG_VERSION")
253            ))]))
254        }
255        Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
256            "āŒ Failed to generate commit message: {}",
257            e
258        ))])),
259    }
260}
261
262/// Show commit prompt via MCP
263async fn show_commit_prompt_mcp(
264    arguments: &Option<serde_json::Map<String, serde_json::Value>>,
265) -> Result<CallToolResult, McpError> {
266    // Ensure we're in a git repository
267    if let Err(e) = git::assert_git_repo() {
268        return Ok(CallToolResult::error(vec![Content::text(format!(
269            "āŒ Error: Not a git repository: {}",
270            e
271        ))]));
272    }
273
274    // Load configuration
275    let mut config = match Config::load() {
276        Ok(c) => c,
277        Err(e) => {
278            return Ok(CallToolResult::error(vec![Content::text(format!(
279                "āŒ Configuration error: {}",
280                e
281            ))]));
282        }
283    };
284
285    // Apply commitlint rules (log warnings but don't fail)
286    if let Err(e) = config.load_with_commitlint() {
287        tracing::warn!("Failed to load commitlint config: {}", e);
288    }
289    if let Err(e) = config.apply_commitlint_rules() {
290        tracing::warn!("Failed to apply commitlint rules: {}", e);
291    }
292
293    // Get staged diff
294    let diff = match git::get_staged_diff() {
295        Ok(d) => d,
296        Err(e) => {
297            return Ok(CallToolResult::error(vec![Content::text(format!(
298                "āŒ Git error: {}",
299                e
300            ))]));
301        }
302    };
303
304    if diff.is_empty() {
305        return Ok(CallToolResult::success(vec![Content::text(
306            "āš ļø  No staged changes found. Please stage your changes with 'git add' first.",
307        )]));
308    }
309
310    // Extract arguments
311    let args = arguments
312        .as_ref()
313        .map(|map| serde_json::Value::Object(map.clone()))
314        .unwrap_or(serde_json::json!({}));
315    let context = args["context"].as_str();
316    let full_gitmoji = args["full_gitmoji"].as_bool().unwrap_or(false);
317
318    // Generate prompt
319    let prompt = config.get_effective_prompt(&diff, context, full_gitmoji);
320    let provider_name = config.ai_provider.as_deref().unwrap_or("openai");
321    let model_name = config.model.as_deref().unwrap_or("default");
322
323    Ok(CallToolResult::success(vec![Content::text(format!(
324        "šŸ” **AI Prompt Preview:**\n\n```\n{}\n```\n\n**Configuration:**\n- Provider: {}\n- Model: {}\n- Generated by: Rusty Commit v{}\n\nšŸ’” This is the exact prompt that would be sent to the AI model.",
325        prompt,
326        provider_name,
327        model_name,
328        env!("CARGO_PKG_VERSION")
329    ))]))
330}
331
332/// Internal commit message generation (reused from commit.rs logic)
333async fn generate_commit_message_internal(
334    config: &Config,
335    diff: &str,
336    context: Option<&str>,
337    full_gitmoji: bool,
338) -> Result<String> {
339    use crate::providers;
340
341    let provider = providers::create_provider(config)?;
342    let message = provider
343        .generate_commit_message(diff, context, full_gitmoji, config)
344        .await?;
345
346    Ok(message)
347}