tanuki_mcp/server/
handler.rs

1//! MCP server handler
2//!
3//! Implements the MCP protocol handler for GitLab tools.
4
5use crate::access_control::AccessResolver;
6use crate::config::AppConfig;
7use crate::dashboard::DashboardMetrics;
8use crate::gitlab::GitLabClient;
9use crate::tools::{ContentBlock, ToolContext, ToolOutput, ToolRegistry, definitions};
10use base64::Engine;
11use rmcp::ErrorData as McpError;
12use rmcp::handler::server::ServerHandler;
13use rmcp::model::{
14    CallToolRequestParam, CallToolResult, CompleteRequestParam, CompleteResult, CompletionInfo,
15    Content, ErrorCode, GetPromptRequestParam, GetPromptResult, Implementation, InitializeResult,
16    ListPromptsResult, ListResourcesResult, ListToolsResult, PaginatedRequestParam, Prompt,
17    PromptArgument, PromptMessage, PromptMessageRole, PromptsCapability, ProtocolVersion,
18    ReadResourceRequestParam, ReadResourceResult, ResourceContents, ResourcesCapability,
19    ServerCapabilities, Tool, ToolsCapability,
20};
21use rmcp::service::{RequestContext, RoleServer};
22use serde_json::{Map, Value};
23use std::borrow::Cow;
24use std::future::Future;
25use std::sync::{Arc, OnceLock};
26use tracing::{debug, error, info, instrument};
27
28/// GitLab MCP server handler
29#[derive(Clone)]
30pub struct GitLabMcpHandler {
31    /// Server name for MCP
32    name: String,
33    /// Server version
34    version: String,
35    /// Tool registry
36    registry: Arc<ToolRegistry>,
37    /// GitLab client
38    gitlab: Arc<GitLabClient>,
39    /// Access control resolver
40    access: Arc<AccessResolver>,
41    /// Dashboard metrics (optional)
42    metrics: Option<Arc<DashboardMetrics>>,
43    /// Cached tool list (lazy-initialized, shared across clones)
44    cached_tools: Arc<OnceLock<Vec<Tool>>>,
45}
46
47impl GitLabMcpHandler {
48    /// Create a new tool registry with all tools registered
49    fn create_registry() -> Arc<ToolRegistry> {
50        let mut registry = ToolRegistry::new();
51        definitions::register_all_tools(&mut registry);
52        Arc::new(registry)
53    }
54
55    /// Create a new handler from configuration
56    pub fn new(config: &AppConfig, gitlab: GitLabClient, access: AccessResolver) -> Self {
57        Self::new_with_shared(config, Arc::new(gitlab), Arc::new(access))
58    }
59
60    /// Create a new handler with shared (Arc-wrapped) resources
61    ///
62    /// This is useful when creating multiple handlers that share the same
63    /// GitLab client and access resolver (e.g., for HTTP transport with
64    /// multiple concurrent connections).
65    pub fn new_with_shared(
66        config: &AppConfig,
67        gitlab: Arc<GitLabClient>,
68        access: Arc<AccessResolver>,
69    ) -> Self {
70        let registry = Self::create_registry();
71        info!(tools = registry.len(), "Initialized GitLab MCP handler");
72
73        Self {
74            name: config.server.name.clone(),
75            version: config.server.version.clone(),
76            registry,
77            gitlab,
78            access,
79            metrics: None,
80            cached_tools: Arc::new(OnceLock::new()),
81        }
82    }
83
84    /// Create a new handler with shared resources and metrics
85    pub fn new_with_metrics(
86        config: &AppConfig,
87        gitlab: Arc<GitLabClient>,
88        access: Arc<AccessResolver>,
89        metrics: Arc<DashboardMetrics>,
90    ) -> Self {
91        let registry = Self::create_registry();
92        info!(
93            tools = registry.len(),
94            "Initialized GitLab MCP handler with metrics"
95        );
96
97        Self {
98            name: config.server.name.clone(),
99            version: config.server.version.clone(),
100            registry,
101            gitlab,
102            access,
103            metrics: Some(metrics),
104            cached_tools: Arc::new(OnceLock::new()),
105        }
106    }
107
108    /// Get the number of registered tools
109    pub fn tool_count(&self) -> usize {
110        self.registry.len()
111    }
112
113    /// Create tool context for a request
114    fn create_context(&self, request_id: &str) -> ToolContext {
115        match &self.metrics {
116            Some(metrics) => ToolContext::with_metrics(
117                self.gitlab.clone(),
118                self.access.clone(),
119                request_id,
120                metrics.clone(),
121            ),
122            None => ToolContext::new(self.gitlab.clone(), self.access.clone(), request_id),
123        }
124    }
125
126    /// Convert internal tool output to MCP result
127    fn to_mcp_result(&self, output: ToolOutput) -> CallToolResult {
128        let content = output
129            .content
130            .into_iter()
131            .map(|block| match block {
132                ContentBlock::Text { text } => Content::text(text),
133                ContentBlock::Image { data, mime_type } => {
134                    // rmcp supports images via Content::image(data, mime_type)
135                    Content::image(data, mime_type)
136                }
137                ContentBlock::Resource { uri, text, .. } => {
138                    // Convert resource to text representation
139                    Content::text(text.unwrap_or_else(|| format!("[Resource: {}]", uri)))
140                }
141            })
142            .collect();
143
144        CallToolResult {
145            content,
146            is_error: Some(output.is_error),
147            meta: None,
148            structured_content: None,
149        }
150    }
151
152    /// Convert registry tools to MCP tool definitions
153    ///
154    /// Tools that are globally denied (denied everywhere, no project grants access)
155    /// will have their description prefixed with "UNAVAILABLE: ".
156    ///
157    /// The tool list is cached after first generation for performance.
158    fn get_mcp_tools(&self) -> Vec<Tool> {
159        self.cached_tools
160            .get_or_init(|| {
161                self.registry
162                    .tools()
163                    .map(|tool| {
164                        // Convert schemars schema to MCP format (JsonObject = Map<String, Value>)
165                        let schema_value = serde_json::to_value(&tool.input_schema)
166                            .unwrap_or_else(|_| serde_json::json!({}));
167
168                        // Build the input schema as a JsonObject
169                        let mut input_schema: Map<String, Value> = Map::new();
170                        input_schema
171                            .insert("type".to_string(), Value::String("object".to_string()));
172
173                        if let Some(props) = schema_value.get("properties") {
174                            input_schema.insert("properties".to_string(), props.clone());
175                        }
176                        if let Some(required) = schema_value.get("required") {
177                            input_schema.insert("required".to_string(), required.clone());
178                        }
179
180                        // Check if this tool is globally denied (denied everywhere)
181                        let is_globally_denied = self.access.is_globally_denied(
182                            tool.name,
183                            tool.category,
184                            tool.operation,
185                        );
186
187                        // Build description - prefix with UNAVAILABLE if globally denied
188                        let description = if is_globally_denied {
189                            format!("UNAVAILABLE: {}", tool.description)
190                        } else {
191                            tool.description.to_string()
192                        };
193
194                        Tool {
195                            name: Cow::Owned(tool.name.to_string()),
196                            description: Some(Cow::Owned(description)),
197                            input_schema: Arc::new(input_schema),
198                            annotations: None,
199                            icons: None,
200                            meta: None,
201                            output_schema: None,
202                            title: None,
203                        }
204                    })
205                    .collect()
206            })
207            .clone()
208    }
209
210    /// Get tool names for completion, filtered by prefix
211    fn get_tool_completions(&self, prefix: &str) -> Vec<String> {
212        self.registry
213            .tools()
214            .filter(|tool| tool.name.starts_with(prefix))
215            .map(|tool| tool.name.to_string())
216            .collect()
217    }
218
219    /// Execute a tool call
220    async fn execute_tool(
221        &self,
222        name: &str,
223        arguments: Option<Map<String, Value>>,
224    ) -> CallToolResult {
225        // Generate a request ID for tracing
226        let request_id = format!("{:x}", rand::random::<u64>());
227        let ctx = self.create_context(&request_id);
228
229        // Get arguments or empty object - convert Map to Value
230        let args = arguments
231            .map(Value::Object)
232            .unwrap_or_else(|| serde_json::json!({}));
233
234        // Execute the tool
235        let result = self.registry.execute(name, &ctx, args).await;
236
237        match result {
238            Ok(output) => self.to_mcp_result(output),
239            Err(e) => {
240                error!(error = %e, "Tool execution failed");
241                CallToolResult {
242                    content: vec![Content::text(format!("Error: {}", e))],
243                    is_error: Some(true),
244                    meta: None,
245                    structured_content: None,
246                }
247            }
248        }
249    }
250
251    /// Build the analyze_issue prompt
252    async fn build_analyze_issue_prompt(
253        &self,
254        arguments: Option<Map<String, Value>>,
255    ) -> Result<GetPromptResult, McpError> {
256        let args = arguments.ok_or_else(|| missing_argument("arguments required"))?;
257
258        let project = args
259            .get("project")
260            .and_then(|v| v.as_str())
261            .ok_or_else(|| missing_argument("project"))?;
262
263        let issue_iid = args
264            .get("issue_iid")
265            .and_then(|v| {
266                v.as_str()
267                    .map(String::from)
268                    .or_else(|| v.as_u64().map(|n| n.to_string()))
269            })
270            .ok_or_else(|| missing_argument("issue_iid"))?;
271
272        // Fetch issue details
273        let encoded_project = GitLabClient::encode_project(project);
274        let issue_endpoint = format!("/projects/{}/issues/{}", encoded_project, issue_iid);
275
276        let issue: Value = self
277            .gitlab
278            .get(&issue_endpoint)
279            .await
280            .map_err(|e| internal_error(format!("Failed to fetch issue: {}", e)))?;
281
282        // Fetch issue discussions
283        let discussions_endpoint = format!("{}/discussions", issue_endpoint);
284        let discussions: Value = self
285            .gitlab
286            .get(&discussions_endpoint)
287            .await
288            .unwrap_or_else(|_| serde_json::json!([]));
289
290        // Build the prompt message
291        let title = issue
292            .get("title")
293            .and_then(|v| v.as_str())
294            .unwrap_or("Unknown");
295        let description = issue
296            .get("description")
297            .and_then(|v| v.as_str())
298            .unwrap_or("");
299        let state = issue
300            .get("state")
301            .and_then(|v| v.as_str())
302            .unwrap_or("unknown");
303        let author = issue
304            .get("author")
305            .and_then(|a| a.get("username"))
306            .and_then(|v| v.as_str())
307            .unwrap_or("unknown");
308        let labels = issue
309            .get("labels")
310            .and_then(|v| v.as_array())
311            .map(|arr| {
312                arr.iter()
313                    .filter_map(|l| l.as_str())
314                    .collect::<Vec<_>>()
315                    .join(", ")
316            })
317            .unwrap_or_default();
318
319        let mut prompt_text = format!(
320            "# Issue Analysis: {} #{}\n\n\
321            **Project:** {}\n\
322            **State:** {}\n\
323            **Author:** {}\n\
324            **Labels:** {}\n\n\
325            ## Description\n\n{}\n\n",
326            title, issue_iid, project, state, author, labels, description
327        );
328
329        // Add discussions if present
330        if let Some(disc_array) = discussions.as_array()
331            && !disc_array.is_empty()
332        {
333            prompt_text.push_str("## Discussions\n\n");
334            for (i, discussion) in disc_array.iter().enumerate() {
335                if let Some(notes) = discussion.get("notes").and_then(|n| n.as_array()) {
336                    for note in notes {
337                        let note_author = note
338                            .get("author")
339                            .and_then(|a| a.get("username"))
340                            .and_then(|v| v.as_str())
341                            .unwrap_or("unknown");
342                        let note_body = note.get("body").and_then(|v| v.as_str()).unwrap_or("");
343                        prompt_text.push_str(&format!(
344                            "### Comment {} by @{}\n\n{}\n\n",
345                            i + 1,
346                            note_author,
347                            note_body
348                        ));
349                    }
350                }
351            }
352        }
353
354        prompt_text.push_str(
355            "\n---\n\n\
356            Please analyze this issue and provide:\n\
357            1. A summary of the issue and its current status\n\
358            2. Key points from the discussions\n\
359            3. Suggested next steps or actions\n\
360            4. Any potential blockers or concerns",
361        );
362
363        Ok(GetPromptResult {
364            description: Some(format!("Analysis of issue #{} in {}", issue_iid, project)),
365            messages: vec![PromptMessage::new_text(
366                PromptMessageRole::User,
367                prompt_text,
368            )],
369        })
370    }
371
372    /// Build the review_merge_request prompt
373    async fn build_review_mr_prompt(
374        &self,
375        arguments: Option<Map<String, Value>>,
376    ) -> Result<GetPromptResult, McpError> {
377        let args = arguments.ok_or_else(|| missing_argument("arguments required"))?;
378
379        let project = args
380            .get("project")
381            .and_then(|v| v.as_str())
382            .ok_or_else(|| missing_argument("project"))?;
383
384        let mr_iid = args
385            .get("mr_iid")
386            .and_then(|v| {
387                v.as_str()
388                    .map(String::from)
389                    .or_else(|| v.as_u64().map(|n| n.to_string()))
390            })
391            .ok_or_else(|| missing_argument("mr_iid"))?;
392
393        // Fetch MR details
394        let encoded_project = GitLabClient::encode_project(project);
395        let mr_endpoint = format!("/projects/{}/merge_requests/{}", encoded_project, mr_iid);
396
397        let mr: Value = self
398            .gitlab
399            .get(&mr_endpoint)
400            .await
401            .map_err(|e| internal_error(format!("Failed to fetch merge request: {}", e)))?;
402
403        // Fetch MR changes (diff)
404        let changes_endpoint = format!("{}/changes", mr_endpoint);
405        let changes: Value = self
406            .gitlab
407            .get(&changes_endpoint)
408            .await
409            .unwrap_or_else(|_| serde_json::json!({"changes": []}));
410
411        // Fetch MR discussions
412        let discussions_endpoint = format!("{}/discussions", mr_endpoint);
413        let discussions: Value = self
414            .gitlab
415            .get(&discussions_endpoint)
416            .await
417            .unwrap_or_else(|_| serde_json::json!([]));
418
419        // Build the prompt message
420        let title = mr
421            .get("title")
422            .and_then(|v| v.as_str())
423            .unwrap_or("Unknown");
424        let description = mr.get("description").and_then(|v| v.as_str()).unwrap_or("");
425        let state = mr
426            .get("state")
427            .and_then(|v| v.as_str())
428            .unwrap_or("unknown");
429        let source_branch = mr
430            .get("source_branch")
431            .and_then(|v| v.as_str())
432            .unwrap_or("unknown");
433        let target_branch = mr
434            .get("target_branch")
435            .and_then(|v| v.as_str())
436            .unwrap_or("unknown");
437        let author = mr
438            .get("author")
439            .and_then(|a| a.get("username"))
440            .and_then(|v| v.as_str())
441            .unwrap_or("unknown");
442        let labels = mr
443            .get("labels")
444            .and_then(|v| v.as_array())
445            .map(|arr| {
446                arr.iter()
447                    .filter_map(|l| l.as_str())
448                    .collect::<Vec<_>>()
449                    .join(", ")
450            })
451            .unwrap_or_default();
452
453        let mut prompt_text = format!(
454            "# Merge Request Review: {} !{}\n\n\
455            **Project:** {}\n\
456            **State:** {}\n\
457            **Author:** {}\n\
458            **Source Branch:** {}\n\
459            **Target Branch:** {}\n\
460            **Labels:** {}\n\n\
461            ## Description\n\n{}\n\n",
462            title,
463            mr_iid,
464            project,
465            state,
466            author,
467            source_branch,
468            target_branch,
469            labels,
470            description
471        );
472
473        // Add changes summary
474        if let Some(changes_array) = changes.get("changes").and_then(|c| c.as_array()) {
475            prompt_text.push_str("## Changes\n\n");
476            for change in changes_array {
477                let old_path = change
478                    .get("old_path")
479                    .and_then(|v| v.as_str())
480                    .unwrap_or("");
481                let new_path = change
482                    .get("new_path")
483                    .and_then(|v| v.as_str())
484                    .unwrap_or("");
485                let diff = change.get("diff").and_then(|v| v.as_str()).unwrap_or("");
486
487                if old_path != new_path && !old_path.is_empty() {
488                    prompt_text.push_str(&format!("### {} → {}\n\n", old_path, new_path));
489                } else {
490                    prompt_text.push_str(&format!("### {}\n\n", new_path));
491                }
492
493                // Truncate very long diffs
494                let truncated_diff = if diff.len() > 2000 {
495                    format!("{}...\n(diff truncated)", &diff[..2000])
496                } else {
497                    diff.to_string()
498                };
499                prompt_text.push_str(&format!("```diff\n{}\n```\n\n", truncated_diff));
500            }
501        }
502
503        // Add discussions if present
504        if let Some(disc_array) = discussions.as_array() {
505            let review_comments: Vec<_> = disc_array
506                .iter()
507                .filter(|d| {
508                    d.get("notes")
509                        .and_then(|n| n.as_array())
510                        .map(|notes| {
511                            notes
512                                .iter()
513                                .any(|n| n.get("type").and_then(|t| t.as_str()) == Some("DiffNote"))
514                        })
515                        .unwrap_or(false)
516                })
517                .collect();
518
519            if !review_comments.is_empty() {
520                prompt_text.push_str("## Review Comments\n\n");
521                for discussion in review_comments {
522                    if let Some(notes) = discussion.get("notes").and_then(|n| n.as_array()) {
523                        for note in notes {
524                            let note_author = note
525                                .get("author")
526                                .and_then(|a| a.get("username"))
527                                .and_then(|v| v.as_str())
528                                .unwrap_or("unknown");
529                            let note_body = note.get("body").and_then(|v| v.as_str()).unwrap_or("");
530                            let resolved = note
531                                .get("resolved")
532                                .and_then(|v| v.as_bool())
533                                .map(|r| if r { " ✓" } else { "" })
534                                .unwrap_or("");
535                            prompt_text.push_str(&format!(
536                                "- **@{}**{}: {}\n",
537                                note_author, resolved, note_body
538                            ));
539                        }
540                    }
541                }
542                prompt_text.push('\n');
543            }
544        }
545
546        prompt_text.push_str(
547            "\n---\n\n\
548            Please review this merge request and provide:\n\
549            1. A summary of the changes\n\
550            2. Code quality assessment\n\
551            3. Potential issues or concerns\n\
552            4. Suggestions for improvement\n\
553            5. Overall recommendation (approve/request changes)",
554        );
555
556        Ok(GetPromptResult {
557            description: Some(format!(
558                "Review of merge request !{} in {}",
559                mr_iid, project
560            )),
561            messages: vec![PromptMessage::new_text(
562                PromptMessageRole::User,
563                prompt_text,
564            )],
565        })
566    }
567}
568
569impl ServerHandler for GitLabMcpHandler {
570    fn get_info(&self) -> InitializeResult {
571        InitializeResult {
572            protocol_version: ProtocolVersion::default(),
573            capabilities: ServerCapabilities {
574                tools: Some(ToolsCapability {
575                    list_changed: Some(false),
576                }),
577                completions: Some(Map::new()),
578                resources: Some(ResourcesCapability {
579                    subscribe: Some(false),
580                    list_changed: Some(false),
581                }),
582                prompts: Some(PromptsCapability {
583                    list_changed: Some(false),
584                }),
585                ..Default::default()
586            },
587            server_info: Implementation {
588                name: self.name.clone(),
589                version: self.version.clone(),
590                icons: None,
591                title: None,
592                website_url: None,
593            },
594            instructions: Some(
595                "GitLab MCP Server - Access GitLab resources with fine-grained access control"
596                    .to_string(),
597            ),
598        }
599    }
600
601    #[instrument(skip(self, _context))]
602    fn list_tools(
603        &self,
604        _request: Option<PaginatedRequestParam>,
605        _context: RequestContext<RoleServer>,
606    ) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
607        debug!("Listing tools");
608        async move {
609            Ok(ListToolsResult {
610                tools: self.get_mcp_tools(),
611                next_cursor: None,
612                meta: None,
613            })
614        }
615    }
616
617    #[instrument(skip(self, _context), fields(tool = %request.name))]
618    fn call_tool(
619        &self,
620        request: CallToolRequestParam,
621        _context: RequestContext<RoleServer>,
622    ) -> impl Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
623        debug!(?request.arguments, "Calling tool");
624        async move { Ok(self.execute_tool(&request.name, request.arguments).await) }
625    }
626
627    #[instrument(skip(self, _context))]
628    fn complete(
629        &self,
630        request: CompleteRequestParam,
631        _context: RequestContext<RoleServer>,
632    ) -> impl Future<Output = Result<CompleteResult, McpError>> + Send + '_ {
633        debug!(?request, "Processing completion request");
634        async move {
635            // Extract the argument name being completed
636            let arg_name = &request.argument.name;
637            let prefix = &request.argument.value;
638
639            // Get completions based on what's being completed
640            let values = match arg_name.as_str() {
641                // For tool references, suggest tool names
642                "name" => self.get_tool_completions(prefix),
643                // For project parameters, we could suggest projects
644                // but that would require an API call - return empty for now
645                "project" | "project_id" => Vec::new(),
646                // Default: no completions
647                _ => Vec::new(),
648            };
649
650            let total = values.len() as u32;
651            let has_more = values.len() > 100;
652            let truncated = if has_more {
653                values.into_iter().take(100).collect()
654            } else {
655                values
656            };
657
658            Ok(CompleteResult {
659                completion: CompletionInfo {
660                    values: truncated,
661                    total: Some(total),
662                    has_more: Some(has_more),
663                },
664            })
665        }
666    }
667
668    /// List available resources
669    ///
670    /// Returns an empty list - resources are accessed directly by URI.
671    /// The `gitlab://` URI scheme allows clients to read files from repositories.
672    async fn list_resources(
673        &self,
674        _request: Option<PaginatedRequestParam>,
675        _context: RequestContext<RoleServer>,
676    ) -> Result<ListResourcesResult, McpError> {
677        Ok(ListResourcesResult {
678            resources: vec![],
679            next_cursor: None,
680            meta: None,
681        })
682    }
683
684    /// Read a GitLab file resource by URI
685    ///
686    /// URI format: `gitlab://{project}/{path}?ref={branch}`
687    /// - project: URL-encoded project path (e.g., `group%2Fsubgroup%2Fproject`)
688    /// - path: File path within repository
689    /// - ref: Optional git reference (branch, tag, commit) - defaults to HEAD
690    #[instrument(skip(self, _context))]
691    fn read_resource(
692        &self,
693        request: ReadResourceRequestParam,
694        _context: RequestContext<RoleServer>,
695    ) -> impl Future<Output = Result<ReadResourceResult, McpError>> + Send + '_ {
696        debug!(uri = %request.uri, "Reading resource");
697        async move {
698            // Parse gitlab://project/path?ref=branch URI
699            let (project, file_path, ref_name) = parse_gitlab_uri(&request.uri)?;
700
701            // Build GitLab API endpoint
702            let encoded_project = GitLabClient::encode_project(&project);
703            let encoded_path = urlencoding::encode(&file_path);
704            let ref_param = ref_name.as_deref().unwrap_or("HEAD");
705            let endpoint = format!(
706                "/projects/{}/repository/files/{}?ref={}",
707                encoded_project,
708                encoded_path,
709                urlencoding::encode(ref_param)
710            );
711
712            // Fetch file content
713            let result: serde_json::Value = self
714                .gitlab
715                .get(&endpoint)
716                .await
717                .map_err(|e| internal_error(format!("GitLab API error: {}", e)))?;
718
719            // Decode base64 content if present
720            let content = if let Some(content_str) = result.get("content").and_then(|c| c.as_str())
721            {
722                if result
723                    .get("encoding")
724                    .and_then(|e| e.as_str())
725                    .map(|e| e == "base64")
726                    .unwrap_or(false)
727                {
728                    // Decode base64
729                    let decoded = base64::engine::general_purpose::STANDARD
730                        .decode(content_str)
731                        .map_err(|e| internal_error(format!("Failed to decode base64: {}", e)))?;
732                    String::from_utf8(decoded)
733                        .map_err(|e| internal_error(format!("Invalid UTF-8 content: {}", e)))?
734                } else {
735                    content_str.to_string()
736                }
737            } else {
738                return Err(internal_error("No content in response"));
739            };
740
741            // Determine MIME type from file path
742            let mime_type = guess_mime_type(&file_path);
743
744            Ok(ReadResourceResult {
745                contents: vec![ResourceContents::TextResourceContents {
746                    uri: request.uri,
747                    mime_type: Some(mime_type),
748                    text: content,
749                    meta: None,
750                }],
751            })
752        }
753    }
754
755    /// List available prompts
756    ///
757    /// Returns built-in workflow prompts for GitLab operations.
758    async fn list_prompts(
759        &self,
760        _request: Option<PaginatedRequestParam>,
761        _context: RequestContext<RoleServer>,
762    ) -> Result<ListPromptsResult, McpError> {
763        Ok(ListPromptsResult {
764            prompts: vec![
765                Prompt::new(
766                    "analyze_issue",
767                    Some("Analyze a GitLab issue with discussions and related MRs"),
768                    Some(vec![
769                        PromptArgument {
770                            name: "project".to_string(),
771                            title: Some("Project".to_string()),
772                            description: Some("Project path (e.g., 'group/project')".to_string()),
773                            required: Some(true),
774                        },
775                        PromptArgument {
776                            name: "issue_iid".to_string(),
777                            title: Some("Issue IID".to_string()),
778                            description: Some("Issue internal ID number".to_string()),
779                            required: Some(true),
780                        },
781                    ]),
782                ),
783                Prompt::new(
784                    "review_merge_request",
785                    Some("Review a merge request with changes and discussions"),
786                    Some(vec![
787                        PromptArgument {
788                            name: "project".to_string(),
789                            title: Some("Project".to_string()),
790                            description: Some("Project path (e.g., 'group/project')".to_string()),
791                            required: Some(true),
792                        },
793                        PromptArgument {
794                            name: "mr_iid".to_string(),
795                            title: Some("MR IID".to_string()),
796                            description: Some("Merge request internal ID number".to_string()),
797                            required: Some(true),
798                        },
799                    ]),
800                ),
801            ],
802            next_cursor: None,
803            meta: None,
804        })
805    }
806
807    /// Get a specific prompt by name
808    ///
809    /// Builds workflow prompts that fetch relevant GitLab data.
810    #[instrument(skip(self, _context))]
811    fn get_prompt(
812        &self,
813        request: GetPromptRequestParam,
814        _context: RequestContext<RoleServer>,
815    ) -> impl Future<Output = Result<GetPromptResult, McpError>> + Send + '_ {
816        debug!(name = %request.name, "Getting prompt");
817        async move {
818            match request.name.as_str() {
819                "analyze_issue" => self.build_analyze_issue_prompt(request.arguments).await,
820                "review_merge_request" => self.build_review_mr_prompt(request.arguments).await,
821                _ => Err(method_not_found(&request.name)),
822            }
823        }
824    }
825}
826
827/// Parse a gitlab:// URI into (project, path, ref_name)
828///
829/// URI format: `gitlab://{project}/{path}?ref={branch}`
830/// - project: URL-encoded project path
831/// - path: File path within repository
832/// - ref: Optional git reference
833fn parse_gitlab_uri(uri: &str) -> Result<(String, String, Option<String>), McpError> {
834    // Check scheme
835    let rest = uri
836        .strip_prefix("gitlab://")
837        .ok_or_else(|| invalid_resource_uri("URI must start with 'gitlab://'"))?;
838
839    // Split query string
840    let (path_part, query) = match rest.split_once('?') {
841        Some((p, q)) => (p, Some(q)),
842        None => (rest, None),
843    };
844
845    // Split into project and file path
846    // The project can contain "/" so we need to be smart about this
847    // Project is the first segment(s) until we find a valid file path
848    // For simplicity, we require the project to be URL-encoded (group%2Fproject)
849    // or just a single segment
850    let parts: Vec<&str> = path_part.splitn(2, '/').collect();
851    if parts.len() < 2 {
852        return Err(invalid_resource_uri(
853            "URI must contain project and file path",
854        ));
855    }
856
857    let project = urlencoding::decode(parts[0])
858        .map_err(|_| invalid_resource_uri("Invalid URL encoding in project"))?
859        .to_string();
860    let file_path = parts[1].to_string();
861
862    // Parse ref from query string
863    let ref_name = query.and_then(|q| {
864        q.split('&').find_map(|param| {
865            param
866                .strip_prefix("ref=")
867                .and_then(|v| urlencoding::decode(v).map(|s| s.to_string()).ok())
868        })
869    });
870
871    Ok((project, file_path, ref_name))
872}
873
874/// Create an internal error McpError
875fn internal_error(message: impl Into<Cow<'static, str>>) -> McpError {
876    McpError {
877        code: ErrorCode(-32603), // Internal error
878        message: message.into(),
879        data: None,
880    }
881}
882
883/// Create an invalid resource URI error
884fn invalid_resource_uri(message: impl Into<Cow<'static, str>>) -> McpError {
885    McpError {
886        code: ErrorCode(-32602), // Invalid params
887        message: message.into(),
888        data: None,
889    }
890}
891
892/// Create a missing argument error
893fn missing_argument(arg_name: &str) -> McpError {
894    McpError {
895        code: ErrorCode(-32602), // Invalid params
896        message: format!("Missing required argument: {}", arg_name).into(),
897        data: None,
898    }
899}
900
901/// Create a method not found error
902fn method_not_found(method_name: &str) -> McpError {
903    McpError {
904        code: ErrorCode(-32601), // Method not found
905        message: format!("Unknown prompt: {}", method_name).into(),
906        data: None,
907    }
908}
909
910/// Guess MIME type from file path
911fn guess_mime_type(path: &str) -> String {
912    let ext = path.rsplit('.').next().unwrap_or("");
913    match ext.to_lowercase().as_str() {
914        // Text/code files
915        "rs" => "text/x-rust",
916        "py" => "text/x-python",
917        "js" => "text/javascript",
918        "ts" => "text/typescript",
919        "jsx" => "text/javascript",
920        "tsx" => "text/typescript",
921        "json" => "application/json",
922        "yaml" | "yml" => "text/yaml",
923        "toml" => "text/toml",
924        "md" => "text/markdown",
925        "html" | "htm" => "text/html",
926        "css" => "text/css",
927        "xml" => "text/xml",
928        "sql" => "text/x-sql",
929        "sh" => "text/x-sh",
930        "bash" => "text/x-sh",
931        "zsh" => "text/x-sh",
932        "c" => "text/x-c",
933        "cpp" | "cc" | "cxx" => "text/x-c++",
934        "h" => "text/x-c",
935        "hpp" => "text/x-c++",
936        "java" => "text/x-java",
937        "go" => "text/x-go",
938        "rb" => "text/x-ruby",
939        "php" => "text/x-php",
940        "swift" => "text/x-swift",
941        "kt" | "kts" => "text/x-kotlin",
942        "scala" => "text/x-scala",
943        "txt" => "text/plain",
944        "csv" => "text/csv",
945        "dockerfile" => "text/x-dockerfile",
946        "makefile" => "text/x-makefile",
947        // Default
948        _ => "text/plain",
949    }
950    .to_string()
951}