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
15pub 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
29async 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 let service = server.serve(transport).await?;
47
48 service.waiting().await?;
50
51 Ok(())
52}
53
54#[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
180async fn generate_commit_message_mcp(
182 arguments: &Option<serde_json::Map<String, serde_json::Value>>,
183) -> Result<CallToolResult, McpError> {
184 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 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 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 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 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 if let Some(commit_type) = args["commit_type"].as_str() {
238 config.commit_type = Some(commit_type.to_string());
239 }
240
241 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
262async fn show_commit_prompt_mcp(
264 arguments: &Option<serde_json::Map<String, serde_json::Value>>,
265) -> Result<CallToolResult, McpError> {
266 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 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 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 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 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 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
332async 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}