1use 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#[derive(Clone)]
30pub struct GitLabMcpHandler {
31 name: String,
33 version: String,
35 registry: Arc<ToolRegistry>,
37 gitlab: Arc<GitLabClient>,
39 access: Arc<AccessResolver>,
41 metrics: Option<Arc<DashboardMetrics>>,
43 cached_tools: Arc<OnceLock<Vec<Tool>>>,
45}
46
47impl GitLabMcpHandler {
48 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 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 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 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 pub fn tool_count(&self) -> usize {
110 self.registry.len()
111 }
112
113 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 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 Content::image(data, mime_type)
136 }
137 ContentBlock::Resource { uri, text, .. } => {
138 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 fn get_mcp_tools(&self) -> Vec<Tool> {
159 self.cached_tools
160 .get_or_init(|| {
161 self.registry
162 .tools()
163 .map(|tool| {
164 let schema_value = serde_json::to_value(&tool.input_schema)
166 .unwrap_or_else(|_| serde_json::json!({}));
167
168 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 let is_globally_denied = self.access.is_globally_denied(
182 tool.name,
183 tool.category,
184 tool.operation,
185 );
186
187 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 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 async fn execute_tool(
221 &self,
222 name: &str,
223 arguments: Option<Map<String, Value>>,
224 ) -> CallToolResult {
225 let request_id = format!("{:x}", rand::random::<u64>());
227 let ctx = self.create_context(&request_id);
228
229 let args = arguments
231 .map(Value::Object)
232 .unwrap_or_else(|| serde_json::json!({}));
233
234 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let arg_name = &request.argument.name;
637 let prefix = &request.argument.value;
638
639 let values = match arg_name.as_str() {
641 "name" => self.get_tool_completions(prefix),
643 "project" | "project_id" => Vec::new(),
646 _ => 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 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 #[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 let (project, file_path, ref_name) = parse_gitlab_uri(&request.uri)?;
700
701 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 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 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 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 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 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 #[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
827fn parse_gitlab_uri(uri: &str) -> Result<(String, String, Option<String>), McpError> {
834 let rest = uri
836 .strip_prefix("gitlab://")
837 .ok_or_else(|| invalid_resource_uri("URI must start with 'gitlab://'"))?;
838
839 let (path_part, query) = match rest.split_once('?') {
841 Some((p, q)) => (p, Some(q)),
842 None => (rest, None),
843 };
844
845 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 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
874fn internal_error(message: impl Into<Cow<'static, str>>) -> McpError {
876 McpError {
877 code: ErrorCode(-32603), message: message.into(),
879 data: None,
880 }
881}
882
883fn invalid_resource_uri(message: impl Into<Cow<'static, str>>) -> McpError {
885 McpError {
886 code: ErrorCode(-32602), message: message.into(),
888 data: None,
889 }
890}
891
892fn missing_argument(arg_name: &str) -> McpError {
894 McpError {
895 code: ErrorCode(-32602), message: format!("Missing required argument: {}", arg_name).into(),
897 data: None,
898 }
899}
900
901fn method_not_found(method_name: &str) -> McpError {
903 McpError {
904 code: ErrorCode(-32601), message: format!("Unknown prompt: {}", method_name).into(),
906 data: None,
907 }
908}
909
910fn guess_mime_type(path: &str) -> String {
912 let ext = path.rsplit('.').next().unwrap_or("");
913 match ext.to_lowercase().as_str() {
914 "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 _ => "text/plain",
949 }
950 .to_string()
951}