1use crate::config::ai::AiMcpConfig;
37use crate::config::{Authz, Gateway, UpstreamProtocol};
38use bytes::Bytes;
39use http_body_util::{BodyExt as _, Full};
40use hyper::{Method, Request};
41use hyper_util::client::legacy::Client;
42use hyper_util::rt::TokioExecutor;
43use serde_json::{json, Value};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, BTreeSet};
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::time::Duration;
48
49mod error_code {
51 pub const PARSE_ERROR: i32 = -32700;
52 pub const INVALID_REQUEST: i32 = -32600;
53 pub const METHOD_NOT_FOUND: i32 = -32601;
54 #[allow(dead_code)]
55 pub const INVALID_PARAMS: i32 = -32602;
56 #[allow(dead_code)]
57 pub const INTERNAL_ERROR: i32 = -32603;
58}
59
60const PROTOCOL_VERSION: &str = "2025-06-18";
62
63const SERVER_NAME: &str = "dwara";
65
66const DEFAULT_TTL_SECS: u64 = 3600;
68
69const DEFAULT_MAX_CONCURRENT: usize = 1000;
71
72const DEFAULT_TIMEOUT_MS: u64 = 30_000;
74
75static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
81
82fn generate_session_id(consumer: &str) -> String {
87 let n = std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .map(|d| d.as_nanos())
90 .unwrap_or(0);
91 let c = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
92 let mut hasher = Sha256::new();
93 hasher.update(n.to_le_bytes());
94 hasher.update(c.to_le_bytes());
95 hasher.update(consumer.as_bytes());
96 let hash = hasher.finalize();
97 let hex: String = hash.iter().take(16).map(|b| format!("{b:02x}")).collect();
99 format!("mcp-{hex}")
100}
101
102#[derive(Debug, Clone)]
105pub struct CompiledMcpTool {
106 pub name: String,
108 pub description: String,
110 pub input_schema: Value,
113 pub upstream_url: String,
115 pub method: String,
117 pub timeout_ms: u64,
119 pub authz: Option<Authz>,
122}
123
124#[derive(Debug, Clone)]
127pub struct CompiledMcpResource {
128 pub name: String,
130 pub uri: String,
132 pub description: String,
134 pub mime_type: String,
136 pub content: String,
138}
139
140#[derive(Debug, Clone)]
142pub struct CompiledMcpPrompt {
143 pub name: String,
145 pub description: String,
147 pub template: String,
149 pub arguments: Vec<CompiledMcpPromptArgument>,
151}
152
153#[derive(Debug, Clone)]
155pub struct CompiledMcpPromptArgument {
156 pub name: String,
158 pub description: String,
160 pub required: bool,
162}
163
164#[derive(Debug, Clone)]
168pub struct CompiledMcp {
169 pub tools: BTreeMap<String, CompiledMcpTool>,
171 pub resources: BTreeMap<String, CompiledMcpResource>,
173 pub prompts: BTreeMap<String, CompiledMcpPrompt>,
175 pub sessions_ttl_secs: u64,
177 pub sessions_max_concurrent: usize,
179 pub path: String,
181 pub consumer_tool_allowlists: BTreeMap<String, BTreeSet<String>>,
186}
187
188#[derive(Debug, Clone)]
191pub struct McpToolCallOutcome {
192 pub tool_name: String,
194 pub allowed: bool,
196 pub duration_ms: f64,
198 pub error_code: Option<String>,
200 pub status: String,
202}
203
204#[derive(Debug, Clone)]
208pub struct McpHandleResult {
209 pub response: Option<Value>,
212 pub session_id: Option<String>,
215 pub tool_call: Option<McpToolCallOutcome>,
218 pub session_initialized: bool,
221 pub session_closed: bool,
223}
224
225impl CompiledMcp {
226 pub fn compile(config: &AiMcpConfig, gateway: &Gateway) -> Option<Self> {
233 let sessions = config.sessions.as_ref();
234 let sessions_ttl_secs = sessions
235 .and_then(|s| s.ttl_secs)
236 .unwrap_or(DEFAULT_TTL_SECS);
237 let sessions_max_concurrent = sessions
238 .and_then(|s| s.max_concurrent)
239 .unwrap_or(DEFAULT_MAX_CONCURRENT);
240 let path = config.path.clone().unwrap_or_else(|| "/mcp".to_string());
241
242 let mut tools = BTreeMap::new();
243 let mut consumer_tool_allowlists: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
244 for consumer in &gateway.consumers {
248 if !consumer.tool_allowlist.is_empty() {
249 consumer_tool_allowlists.insert(
250 consumer.name.clone(),
251 consumer.tool_allowlist.iter().cloned().collect(),
252 );
253 }
254 }
255 for (name, tool) in &config.tools {
256 let Some(upstream) = gateway.upstreams.iter().find(|u| u.name == tool.upstream) else {
257 tracing::warn!(
258 code = "mcp_tool_upstream_missing",
259 tool = %name,
260 upstream = %tool.upstream,
261 "mcp tool references unknown upstream; tool dropped"
262 );
263 continue;
264 };
265 let Some(endpoint) = upstream.endpoints.first() else {
266 tracing::warn!(
267 code = "mcp_tool_upstream_no_endpoints",
268 tool = %name,
269 upstream = %tool.upstream,
270 "mcp tool upstream has no endpoints; tool dropped"
271 );
272 continue;
273 };
274 let scheme = match upstream.protocol {
275 UpstreamProtocol::Http1 | UpstreamProtocol::Http2 | UpstreamProtocol::H2c => "http",
276 UpstreamProtocol::Https | UpstreamProtocol::H3 => "https",
277 };
278 let tool_path = tool.path.clone().unwrap_or_else(|| "/".to_string());
279 let upstream_url = format!(
280 "{scheme}://{}:{}{}",
281 endpoint.address, endpoint.port, tool_path
282 );
283 let method = tool
284 .method
285 .clone()
286 .unwrap_or_else(|| "POST".to_string())
287 .to_ascii_uppercase();
288 let timeout_ms = tool.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
289 tools.insert(
290 name.clone(),
291 CompiledMcpTool {
292 name: name.clone(),
293 description: tool.description.clone(),
294 input_schema: tool.input_schema.clone(),
295 upstream_url,
296 method,
297 timeout_ms,
298 authz: tool.authz.clone(),
299 },
300 );
301 }
302 let mut resources = BTreeMap::new();
304 for (name, resource) in &config.resources {
305 resources.insert(
306 name.clone(),
307 CompiledMcpResource {
308 name: name.clone(),
309 uri: resource.uri.clone(),
310 description: resource.description.clone(),
311 mime_type: resource
312 .mime_type
313 .clone()
314 .unwrap_or_else(|| "text/plain".to_string()),
315 content: resource.content.clone(),
316 },
317 );
318 }
319 let mut prompts = BTreeMap::new();
321 for (name, prompt) in &config.prompts {
322 let arguments = prompt
323 .arguments
324 .iter()
325 .map(|a| CompiledMcpPromptArgument {
326 name: a.name.clone(),
327 description: a.description.clone(),
328 required: a.required,
329 })
330 .collect();
331 prompts.insert(
332 name.clone(),
333 CompiledMcpPrompt {
334 name: name.clone(),
335 description: prompt.description.clone(),
336 template: prompt.template.clone(),
337 arguments,
338 },
339 );
340 }
341 Some(CompiledMcp {
342 tools,
343 resources,
344 prompts,
345 sessions_ttl_secs,
346 sessions_max_concurrent,
347 path,
348 consumer_tool_allowlists,
349 })
350 }
351
352 pub fn path(&self) -> &str {
354 &self.path
355 }
356
357 pub fn tool_authz(&self, name: &str) -> Option<&Authz> {
363 self.tools.get(name).and_then(|t| t.authz.as_ref())
364 }
365
366 pub fn tool_names(&self) -> impl Iterator<Item = &str> {
369 self.tools.keys().map(|s| s.as_str())
370 }
371
372 pub fn consumer_allowed_tool(&self, consumer: &str, tool: &str) -> bool {
378 match self.consumer_tool_allowlists.get(consumer) {
379 None => true,
380 Some(allowed) => allowed.contains(tool),
381 }
382 }
383
384 pub async fn handle_request(
397 &self,
398 req: &JsonRpcRequest,
399 session_id: Option<&str>,
400 consumer: &str,
401 ) -> McpHandleResult {
402 let is_notification = req.id.is_none();
404 match req.method.as_str() {
405 "initialize" => self.handle_initialize(req, consumer, is_notification),
406 "notifications/initialized" => {
407 McpHandleResult {
409 response: None,
410 session_id: session_id.map(|s| s.to_string()),
411 tool_call: None,
412 session_initialized: false,
413 session_closed: false,
414 }
415 }
416 "tools/list" => self.handle_tools_list(req, is_notification),
417 "tools/call" => {
418 self.handle_tools_call(req, session_id, consumer, is_notification)
419 .await
420 }
421 "resources/list" => self.handle_resources_list(req, is_notification),
422 "resources/read" => self.handle_resources_read(req, is_notification),
423 "prompts/list" => self.handle_prompts_list(req, is_notification),
424 "prompts/get" => self.handle_prompts_get(req, is_notification),
425 "shutdown" => self.handle_shutdown(req, session_id, is_notification),
426 _ => {
427 let response = if is_notification {
428 None
429 } else {
430 Some(json_rpc_error(
431 req.id.clone(),
432 error_code::METHOD_NOT_FOUND,
433 "method not found",
434 ))
435 };
436 McpHandleResult {
437 response,
438 session_id: session_id.map(|s| s.to_string()),
439 tool_call: None,
440 session_initialized: false,
441 session_closed: false,
442 }
443 }
444 }
445 }
446
447 fn handle_initialize(
448 &self,
449 req: &JsonRpcRequest,
450 consumer: &str,
451 is_notification: bool,
452 ) -> McpHandleResult {
453 let session_id = generate_session_id(consumer);
454 let result = json!({
455 "protocolVersion": PROTOCOL_VERSION,
456 "capabilities": {
457 "tools": {}
458 },
459 "serverInfo": {
460 "name": SERVER_NAME,
461 "version": env!("CARGO_PKG_VERSION")
462 },
463 "sessionId": session_id,
464 });
465 let response = if is_notification {
466 None
467 } else {
468 Some(json_rpc_result(req.id.clone(), result))
469 };
470 McpHandleResult {
471 response,
472 session_id: Some(session_id),
473 tool_call: None,
474 session_initialized: true,
475 session_closed: false,
476 }
477 }
478
479 fn handle_tools_list(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
480 let mut tools_list = Vec::new();
481 for tool in self.tools.values() {
482 tools_list.push(json!({
483 "name": tool.name,
484 "description": tool.description,
485 "inputSchema": tool.input_schema,
486 }));
487 }
488 let result = json!({
489 "tools": tools_list,
490 "nextCursor": null,
491 });
492 let response = if is_notification {
493 None
494 } else {
495 Some(json_rpc_result(req.id.clone(), result))
496 };
497 McpHandleResult {
498 response,
499 session_id: None,
500 tool_call: None,
501 session_initialized: false,
502 session_closed: false,
503 }
504 }
505
506 fn handle_resources_list(
509 &self,
510 req: &JsonRpcRequest,
511 is_notification: bool,
512 ) -> McpHandleResult {
513 let mut resources_list = Vec::new();
514 for resource in self.resources.values() {
515 resources_list.push(json!({
516 "uri": resource.uri,
517 "name": resource.name,
518 "description": resource.description,
519 "mimeType": resource.mime_type,
520 }));
521 }
522 let result = json!({
523 "resources": resources_list,
524 "nextCursor": null,
525 });
526 let response = if is_notification {
527 None
528 } else {
529 Some(json_rpc_result(req.id.clone(), result))
530 };
531 McpHandleResult {
532 response,
533 session_id: None,
534 tool_call: None,
535 session_initialized: false,
536 session_closed: false,
537 }
538 }
539
540 fn handle_resources_read(
543 &self,
544 req: &JsonRpcRequest,
545 is_notification: bool,
546 ) -> McpHandleResult {
547 let params = req.params.clone().unwrap_or(Value::Null);
548 let uri = params.get("uri").and_then(|v| v.as_str()).unwrap_or("");
549 let resource = self.resources.values().find(|r| r.uri == uri);
550 let response = if is_notification {
551 None
552 } else if let Some(resource) = resource {
553 let result = json!({
554 "contents": [{
555 "uri": resource.uri,
556 "mimeType": resource.mime_type,
557 "text": resource.content,
558 }]
559 });
560 Some(json_rpc_result(req.id.clone(), result))
561 } else {
562 Some(json_rpc_error(
563 req.id.clone(),
564 error_code::INVALID_PARAMS,
565 &format!("resource not found: {uri}"),
566 ))
567 };
568 McpHandleResult {
569 response,
570 session_id: None,
571 tool_call: None,
572 session_initialized: false,
573 session_closed: false,
574 }
575 }
576
577 fn handle_prompts_list(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
580 let mut prompts_list = Vec::new();
581 for prompt in self.prompts.values() {
582 let arguments: Vec<Value> = prompt
583 .arguments
584 .iter()
585 .map(|a| {
586 json!({
587 "name": a.name,
588 "description": a.description,
589 "required": a.required,
590 })
591 })
592 .collect();
593 prompts_list.push(json!({
594 "name": prompt.name,
595 "description": prompt.description,
596 "arguments": arguments,
597 }));
598 }
599 let result = json!({
600 "prompts": prompts_list,
601 "nextCursor": null,
602 });
603 let response = if is_notification {
604 None
605 } else {
606 Some(json_rpc_result(req.id.clone(), result))
607 };
608 McpHandleResult {
609 response,
610 session_id: None,
611 tool_call: None,
612 session_initialized: false,
613 session_closed: false,
614 }
615 }
616
617 fn handle_prompts_get(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
620 let params = req.params.clone().unwrap_or(Value::Null);
621 let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
622 let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
623 let prompt = self.prompts.get(name);
624 let response = if is_notification {
625 None
626 } else if let Some(prompt) = prompt {
627 let mut template = prompt.template.clone();
629 if let Some(args_obj) = arguments.as_object() {
630 for (key, value) in args_obj {
631 if let Some(s) = value.as_str() {
632 let placeholder = format!("{{{{{key}}}}}");
633 template = template.replace(&placeholder, s);
634 }
635 }
636 }
637 let result = json!({
638 "description": prompt.description,
639 "messages": [{
640 "role": "user",
641 "content": {
642 "type": "text",
643 "text": template,
644 }
645 }]
646 });
647 Some(json_rpc_result(req.id.clone(), result))
648 } else {
649 Some(json_rpc_error(
650 req.id.clone(),
651 error_code::INVALID_PARAMS,
652 &format!("prompt not found: {name}"),
653 ))
654 };
655 McpHandleResult {
656 response,
657 session_id: None,
658 tool_call: None,
659 session_initialized: false,
660 session_closed: false,
661 }
662 }
663
664 async fn handle_tools_call(
665 &self,
666 req: &JsonRpcRequest,
667 session_id: Option<&str>,
668 _consumer: &str,
669 is_notification: bool,
670 ) -> McpHandleResult {
671 let started = std::time::Instant::now();
672 let params = req.params.clone().unwrap_or(Value::Null);
673 let tool_name = params
674 .get("name")
675 .and_then(|v| v.as_str())
676 .unwrap_or("")
677 .to_string();
678 let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
679
680 let Some(tool) = self.tools.get(&tool_name) else {
681 let outcome = McpToolCallOutcome {
682 tool_name: tool_name.clone(),
683 allowed: true,
684 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
685 error_code: Some("tool_not_found".to_string()),
686 status: "error".to_string(),
687 };
688 let result = json!({
689 "content": [{"type": "text", "text": format!("unknown tool: {tool_name}")}],
690 "isError": true,
691 });
692 let response = if is_notification {
693 None
694 } else {
695 Some(json_rpc_result(req.id.clone(), result))
696 };
697 return McpHandleResult {
698 response,
699 session_id: session_id.map(|s| s.to_string()),
700 tool_call: Some(outcome),
701 session_initialized: false,
702 session_closed: false,
703 };
704 };
705
706 let call_result = call_upstream(tool, &arguments).await;
708 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
709 match call_result {
710 Ok(text) => {
711 let outcome = McpToolCallOutcome {
712 tool_name: tool_name.clone(),
713 allowed: true,
714 duration_ms,
715 error_code: None,
716 status: "success".to_string(),
717 };
718 let result = json!({
719 "content": [{"type": "text", "text": text}],
720 "isError": false,
721 });
722 let response = if is_notification {
723 None
724 } else {
725 Some(json_rpc_result(req.id.clone(), result))
726 };
727 McpHandleResult {
728 response,
729 session_id: session_id.map(|s| s.to_string()),
730 tool_call: Some(outcome),
731 session_initialized: false,
732 session_closed: false,
733 }
734 }
735 Err(err) => {
736 let outcome = McpToolCallOutcome {
737 tool_name: tool_name.clone(),
738 allowed: true,
739 duration_ms,
740 error_code: Some("upstream_error".to_string()),
741 status: "error".to_string(),
742 };
743 let result = json!({
744 "content": [{"type": "text", "text": format!("upstream error: {err}")}],
745 "isError": true,
746 });
747 let response = if is_notification {
748 None
749 } else {
750 Some(json_rpc_result(req.id.clone(), result))
751 };
752 McpHandleResult {
753 response,
754 session_id: session_id.map(|s| s.to_string()),
755 tool_call: Some(outcome),
756 session_initialized: false,
757 session_closed: false,
758 }
759 }
760 }
761 }
762
763 fn handle_shutdown(
764 &self,
765 req: &JsonRpcRequest,
766 session_id: Option<&str>,
767 is_notification: bool,
768 ) -> McpHandleResult {
769 let response = if is_notification {
770 None
771 } else {
772 Some(json_rpc_result(req.id.clone(), json!({})))
773 };
774 McpHandleResult {
775 response,
776 session_id: session_id.map(|s| s.to_string()),
777 tool_call: None,
778 session_initialized: false,
779 session_closed: true,
780 }
781 }
782}
783
784async fn call_upstream(tool: &CompiledMcpTool, arguments: &Value) -> Result<String, String> {
789 let body_bytes =
790 serde_json::to_vec(arguments).map_err(|e| format!("encode request body: {e}"))?;
791 let method = match tool.method.as_str() {
792 "GET" => Method::GET,
793 "POST" => Method::POST,
794 "PUT" => Method::PUT,
795 "PATCH" => Method::PATCH,
796 "DELETE" => Method::DELETE,
797 _ => Method::POST,
798 };
799 let req = Request::builder()
800 .method(method)
801 .uri(&tool.upstream_url)
802 .header("content-type", "application/json")
803 .header("accept", "application/json")
804 .body(Full::new(Bytes::from(body_bytes)))
805 .map_err(|e| format!("build upstream request: {e}"))?;
806 let client = Client::builder(TokioExecutor::new()).build_http();
807 let timeout = Duration::from_millis(tool.timeout_ms);
808 let resp = tokio::time::timeout(timeout, client.request(req))
809 .await
810 .map_err(|_| format!("upstream timed out after {} ms", tool.timeout_ms))?
811 .map_err(|e| format!("upstream request failed: {e}"))?;
812 let status = resp.status();
813 let bytes = resp
814 .into_body()
815 .collect()
816 .await
817 .map_err(|e| format!("read upstream response body: {e}"))?
818 .to_bytes();
819 if !status.is_success() {
820 return Err(format!(
821 "upstream returned {status}: {}",
822 String::from_utf8_lossy(&bytes)
823 ));
824 }
825 Ok(String::from_utf8_lossy(&bytes).into_owned())
826}
827
828#[derive(Debug, Clone)]
832pub struct JsonRpcRequest {
833 pub jsonrpc: String,
835 pub id: Option<Value>,
838 pub method: String,
840 pub params: Option<Value>,
842}
843
844impl JsonRpcRequest {
845 pub fn parse(body: &Value) -> Result<Self, Value> {
848 let obj = body.as_object().ok_or_else(|| {
849 json_rpc_error(
850 None,
851 error_code::INVALID_REQUEST,
852 "request must be a JSON object",
853 )
854 })?;
855 let jsonrpc = obj.get("jsonrpc").and_then(|v| v.as_str()).ok_or_else(|| {
856 json_rpc_error(
857 None,
858 error_code::INVALID_REQUEST,
859 "missing or invalid 'jsonrpc' field",
860 )
861 })?;
862 if jsonrpc != "2.0" {
863 return Err(json_rpc_error(
864 None,
865 error_code::INVALID_REQUEST,
866 "unsupported jsonrpc version (must be '2.0')",
867 ));
868 }
869 let method = obj.get("method").and_then(|v| v.as_str()).ok_or_else(|| {
870 json_rpc_error(
871 None,
872 error_code::INVALID_REQUEST,
873 "missing or invalid 'method' field",
874 )
875 })?;
876 let id = obj.get("id").cloned();
877 let params = obj.get("params").cloned();
878 Ok(JsonRpcRequest {
879 jsonrpc: jsonrpc.to_string(),
880 id,
881 method: method.to_string(),
882 params,
883 })
884 }
885}
886
887pub fn json_rpc_result_pub(id: Option<Value>, result: Value) -> Value {
890 json_rpc_result(id, result)
891}
892
893fn json_rpc_result(id: Option<Value>, result: Value) -> Value {
895 json!({
896 "jsonrpc": "2.0",
897 "id": id.unwrap_or(Value::Null),
898 "result": result,
899 })
900}
901
902fn json_rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
904 json!({
905 "jsonrpc": "2.0",
906 "id": id.unwrap_or(Value::Null),
907 "error": {
908 "code": code,
909 "message": message,
910 },
911 })
912}
913
914pub fn parse_error_response() -> Value {
916 json_rpc_error(None, error_code::PARSE_ERROR, "parse error")
917}
918
919pub fn json_rpc_error_pub(id: Option<Value>, code: i32, message: &str) -> Value {
922 json_rpc_error(id, code, message)
923}