1use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use uuid::Uuid;
13
14use super::{Tool, ToolOutput};
15use crate::error::Error;
16
17const PROTOCOL_VERSION: &str = "2025-11-25";
18
19#[derive(Debug, Deserialize)]
22struct JsonRpcRequest {
23 #[allow(dead_code)]
24 jsonrpc: Option<String>,
25 method: String,
26 #[serde(default)]
27 params: Option<Value>,
28 id: Option<Value>,
29}
30
31#[derive(Debug, Serialize)]
32struct JsonRpcResponse {
33 jsonrpc: &'static str,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 result: Option<Value>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 error: Option<JsonRpcError>,
38 id: Value,
39}
40
41#[derive(Debug, Serialize)]
42struct JsonRpcError {
43 code: i64,
44 message: String,
45}
46
47impl JsonRpcResponse {
48 fn success(id: Value, result: Value) -> Self {
49 Self {
50 jsonrpc: "2.0",
51 result: Some(result),
52 error: None,
53 id,
54 }
55 }
56
57 fn error(id: Value, code: i64, message: impl Into<String>) -> Self {
58 Self {
59 jsonrpc: "2.0",
60 result: None,
61 error: Some(JsonRpcError {
62 code,
63 message: message.into(),
64 }),
65 id,
66 }
67 }
68}
69
70const METHOD_NOT_FOUND: i64 = -32601;
72const INVALID_PARAMS: i64 = -32602;
73const INTERNAL_ERROR: i64 = -32603;
74
75#[derive(Debug, Clone)]
79pub struct McpServerConfig {
80 pub name: String,
82 pub version: String,
84 pub expose_tools: bool,
86 pub expose_resources: bool,
88 pub expose_prompts: bool,
90}
91
92impl Default for McpServerConfig {
93 fn default() -> Self {
94 Self {
95 name: "heartbit".into(),
96 version: env!("CARGO_PKG_VERSION").into(),
97 expose_tools: true,
98 expose_resources: true,
99 expose_prompts: false,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct ServerResource {
108 pub uri: String,
110 pub name: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub description: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub mime_type: Option<String>,
118}
119
120pub type ResourceReader =
122 Arc<dyn Fn(&str) -> Result<Vec<(Option<String>, String)>, Error> + Send + Sync>;
123
124pub type AuthCallback = Arc<dyn Fn(&str, Option<&str>, Option<&str>) -> bool + Send + Sync>;
130
131const UNAUTHORIZED: i64 = -32001;
134
135const MAX_SESSIONS: usize = 256;
142
143const SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); pub struct McpServer {
177 config: McpServerConfig,
178 tools: Vec<Arc<dyn Tool>>,
179 resources: Vec<ServerResource>,
180 resource_reader: Option<ResourceReader>,
181 sessions: parking_lot::RwLock<HashMap<String, Instant>>,
186 auth_callback: Option<AuthCallback>,
187 allow_unauthenticated: bool,
192}
193
194impl McpServer {
195 pub fn new(config: McpServerConfig) -> Self {
205 Self {
206 config,
207 tools: Vec::new(),
208 resources: Vec::new(),
209 resource_reader: None,
210 sessions: parking_lot::RwLock::new(HashMap::new()),
213 auth_callback: None,
214 allow_unauthenticated: false,
215 }
216 }
217
218 pub fn with_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
220 self.tools = tools;
221 self
222 }
223
224 pub fn with_resources(
226 mut self,
227 resources: Vec<ServerResource>,
228 reader: ResourceReader,
229 ) -> Self {
230 self.resources = resources;
231 self.resource_reader = Some(reader);
232 self
233 }
234
235 pub fn with_auth_callback(mut self, callback: AuthCallback) -> Self {
242 self.auth_callback = Some(callback);
243 self
244 }
245
246 pub fn allow_unauthenticated(mut self) -> Self {
260 self.allow_unauthenticated = true;
261 self
262 }
263
264 fn ensure_session(&self, session_id: Option<&str>) -> String {
270 let now = Instant::now();
271
272 if let Some(sid) = session_id {
274 let mut sessions = self.sessions.write();
275 if let Some(last_active) = sessions.get_mut(sid) {
276 if now.duration_since(*last_active) < SESSION_IDLE_TIMEOUT {
277 *last_active = now;
278 return sid.to_string();
279 }
280 sessions.remove(sid);
282 }
283 }
284
285 let new_sid = Uuid::new_v4().to_string();
286 let mut sessions = self.sessions.write();
287
288 sessions.retain(|_, last_active| now.duration_since(*last_active) < SESSION_IDLE_TIMEOUT);
290
291 if sessions.len() >= MAX_SESSIONS
293 && let Some(victim) = sessions.keys().next().cloned()
294 {
295 sessions.remove(&victim);
296 }
297
298 sessions.insert(new_sid.clone(), now);
299 new_sid
300 }
301
302 pub async fn handle_request(&self, body: &str, session_id: Option<&str>) -> (String, String) {
308 self.handle_request_with_auth(body, session_id, None).await
309 }
310
311 pub async fn handle_request_with_auth(
322 &self,
323 body: &str,
324 session_id: Option<&str>,
325 auth_header: Option<&str>,
326 ) -> (String, String) {
327 let sid = self.ensure_session(session_id);
328
329 let response = match serde_json::from_str::<JsonRpcRequest>(body) {
330 Ok(req) => {
331 let authorized = match &self.auth_callback {
337 Some(cb) => cb(&req.method, session_id, auth_header),
338 None => self.allow_unauthenticated,
339 };
340 if !authorized {
341 let id = req.id.clone().unwrap_or(Value::Null);
342 let err = JsonRpcResponse::error(id, UNAUTHORIZED, "Unauthorized");
343 serde_json::to_string(&err).unwrap_or_default()
344 } else {
345 self.route(req).await
346 }
347 }
348 Err(e) => {
349 let err = JsonRpcResponse::error(Value::Null, -32700, format!("Parse error: {e}"));
350 serde_json::to_string(&err).unwrap_or_default()
351 }
352 };
353
354 (response, sid)
355 }
356
357 async fn route(&self, req: JsonRpcRequest) -> String {
358 let id = req.id.clone().unwrap_or(Value::Null);
359 let result = match req.method.as_str() {
360 "initialize" => self.handle_initialize(&id),
361 "ping" => Ok(JsonRpcResponse::success(id.clone(), serde_json::json!({}))),
362 "tools/list" => self.handle_tools_list(&id, req.params.as_ref()),
363 "tools/call" => self.handle_tools_call(&id, req.params.as_ref()).await,
364 "resources/list" => self.handle_resources_list(&id, req.params.as_ref()),
365 "resources/read" => self.handle_resources_read(&id, req.params.as_ref()),
366 _ if req.method.starts_with("notifications/") => {
367 return String::new();
369 }
370 _ => Ok(JsonRpcResponse::error(
371 id.clone(),
372 METHOD_NOT_FOUND,
373 format!("Method not found: {}", req.method),
374 )),
375 };
376
377 match result {
378 Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(),
379 Err(e) => {
380 let resp = JsonRpcResponse::error(id, INTERNAL_ERROR, e.to_string());
381 serde_json::to_string(&resp).unwrap_or_default()
382 }
383 }
384 }
385
386 fn handle_initialize(&self, id: &Value) -> Result<JsonRpcResponse, Error> {
387 let mut capabilities = serde_json::json!({});
388
389 if self.config.expose_tools && !self.tools.is_empty() {
390 capabilities["tools"] = serde_json::json!({ "listChanged": false });
391 }
392 if self.config.expose_resources && !self.resources.is_empty() {
393 capabilities["resources"] =
394 serde_json::json!({ "subscribe": false, "listChanged": false });
395 }
396
397 Ok(JsonRpcResponse::success(
398 id.clone(),
399 serde_json::json!({
400 "protocolVersion": PROTOCOL_VERSION,
401 "capabilities": capabilities,
402 "serverInfo": {
403 "name": self.config.name,
404 "version": self.config.version
405 }
406 }),
407 ))
408 }
409
410 fn handle_tools_list(
411 &self,
412 id: &Value,
413 _params: Option<&Value>,
414 ) -> Result<JsonRpcResponse, Error> {
415 if !self.config.expose_tools {
416 return Ok(JsonRpcResponse::success(
417 id.clone(),
418 serde_json::json!({ "tools": [] }),
419 ));
420 }
421
422 let tools: Vec<Value> = self
423 .tools
424 .iter()
425 .map(|t| {
426 let def = t.definition();
427 serde_json::json!({
428 "name": def.name,
429 "description": def.description,
430 "inputSchema": def.input_schema,
431 })
432 })
433 .collect();
434
435 Ok(JsonRpcResponse::success(
436 id.clone(),
437 serde_json::json!({ "tools": tools }),
438 ))
439 }
440
441 async fn handle_tools_call(
442 &self,
443 id: &Value,
444 params: Option<&Value>,
445 ) -> Result<JsonRpcResponse, Error> {
446 let params = params.ok_or_else(|| Error::Mcp("Missing params for tools/call".into()))?;
447 let name = params
448 .get("name")
449 .and_then(|v| v.as_str())
450 .ok_or_else(|| Error::Mcp("Missing 'name' in tools/call params".into()))?;
451 let arguments = params
452 .get("arguments")
453 .cloned()
454 .unwrap_or(serde_json::json!({}));
455
456 let tool = self
457 .tools
458 .iter()
459 .find(|t| t.definition().name == name)
460 .ok_or_else(|| Error::Mcp(format!("Tool not found: {name}")))?;
461
462 match tool
467 .execute(&crate::ExecutionContext::default(), arguments)
468 .await
469 {
470 Ok(output) => Ok(JsonRpcResponse::success(
471 id.clone(),
472 tool_output_to_mcp(output),
473 )),
474 Err(e) => Ok(JsonRpcResponse::success(
475 id.clone(),
476 serde_json::json!({
477 "content": [{"type": "text", "text": e.to_string()}],
478 "isError": true
479 }),
480 )),
481 }
482 }
483
484 fn handle_resources_list(
485 &self,
486 id: &Value,
487 _params: Option<&Value>,
488 ) -> Result<JsonRpcResponse, Error> {
489 if !self.config.expose_resources {
490 return Ok(JsonRpcResponse::success(
491 id.clone(),
492 serde_json::json!({ "resources": [] }),
493 ));
494 }
495
496 let resources: Vec<Value> = self
497 .resources
498 .iter()
499 .map(|r| serde_json::to_value(r).unwrap_or_default())
500 .collect();
501
502 Ok(JsonRpcResponse::success(
503 id.clone(),
504 serde_json::json!({ "resources": resources }),
505 ))
506 }
507
508 fn handle_resources_read(
509 &self,
510 id: &Value,
511 params: Option<&Value>,
512 ) -> Result<JsonRpcResponse, Error> {
513 let params =
514 params.ok_or_else(|| Error::Mcp("Missing params for resources/read".into()))?;
515 let uri = params
516 .get("uri")
517 .and_then(|v| v.as_str())
518 .ok_or_else(|| Error::Mcp("Missing 'uri' in resources/read params".into()))?;
519
520 if !self.resources.iter().any(|r| r.uri == uri) {
522 return Ok(JsonRpcResponse::error(
523 id.clone(),
524 INVALID_PARAMS,
525 format!("Resource not found: {uri}"),
526 ));
527 }
528
529 let reader = self
530 .resource_reader
531 .as_ref()
532 .ok_or_else(|| Error::Mcp("No resource reader configured".into()))?;
533
534 match reader(uri) {
535 Ok(contents) => {
536 let content_values: Vec<Value> = contents
537 .into_iter()
538 .map(|(mime, text)| {
539 let mut obj = serde_json::json!({
540 "uri": uri,
541 "text": text,
542 });
543 if let Some(m) = mime {
544 obj["mimeType"] = Value::String(m);
545 }
546 obj
547 })
548 .collect();
549 Ok(JsonRpcResponse::success(
550 id.clone(),
551 serde_json::json!({ "contents": content_values }),
552 ))
553 }
554 Err(e) => Ok(JsonRpcResponse::error(
555 id.clone(),
556 INTERNAL_ERROR,
557 e.to_string(),
558 )),
559 }
560 }
561}
562
563fn tool_output_to_mcp(output: ToolOutput) -> Value {
564 serde_json::json!({
565 "content": [{"type": "text", "text": output.content}],
566 "isError": output.is_error
567 })
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use std::future::Future;
574 use std::pin::Pin;
575
576 use crate::llm::types::ToolDefinition;
577 use serde_json::json;
578
579 struct EchoTool;
580
581 impl Tool for EchoTool {
582 fn definition(&self) -> ToolDefinition {
583 ToolDefinition {
584 name: "echo".into(),
585 description: "Echo input".into(),
586 input_schema: json!({
587 "type": "object",
588 "properties": {"text": {"type": "string"}},
589 "required": ["text"]
590 }),
591 }
592 }
593
594 fn execute(
595 &self,
596 _ctx: &crate::ExecutionContext,
597 input: Value,
598 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
599 Box::pin(async move {
600 let text = input
601 .get("text")
602 .and_then(|v| v.as_str())
603 .unwrap_or("no text");
604 Ok(ToolOutput::success(text))
605 })
606 }
607 }
608
609 struct FailTool;
610
611 impl Tool for FailTool {
612 fn definition(&self) -> ToolDefinition {
613 ToolDefinition {
614 name: "fail".into(),
615 description: "Always fails".into(),
616 input_schema: json!({"type": "object"}),
617 }
618 }
619
620 fn execute(
621 &self,
622 _ctx: &crate::ExecutionContext,
623 _input: Value,
624 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
625 Box::pin(async move { Err(Error::Mcp("intentional failure".into())) })
626 }
627 }
628
629 fn make_server() -> McpServer {
630 let echo: Arc<dyn Tool> = Arc::new(EchoTool);
631 let fail: Arc<dyn Tool> = Arc::new(FailTool);
632
633 McpServer::new(McpServerConfig::default())
634 .allow_unauthenticated()
637 .with_tools(vec![echo, fail])
638 .with_resources(
639 vec![
640 ServerResource {
641 uri: "heartbit://tasks/123".into(),
642 name: "task_123".into(),
643 description: Some("Task result".into()),
644 mime_type: Some("text/plain".into()),
645 },
646 ServerResource {
647 uri: "heartbit://config".into(),
648 name: "config".into(),
649 description: None,
650 mime_type: None,
651 },
652 ],
653 Arc::new(|uri: &str| match uri {
654 "heartbit://tasks/123" => {
655 Ok(vec![(Some("text/plain".into()), "Task completed!".into())])
656 }
657 "heartbit://config" => Ok(vec![(None, "key=value".into())]),
658 _ => Err(Error::Mcp(format!("Unknown resource: {uri}"))),
659 }),
660 )
661 }
662
663 #[tokio::test]
666 async fn initialize_returns_capabilities() {
667 let server = make_server();
668 let req = json!({
669 "jsonrpc": "2.0",
670 "method": "initialize",
671 "params": {
672 "protocolVersion": "2025-11-25",
673 "capabilities": {},
674 "clientInfo": {"name": "test", "version": "1.0"}
675 },
676 "id": 1
677 });
678
679 let (resp, sid) = server
680 .handle_request(&serde_json::to_string(&req).unwrap(), None)
681 .await;
682 let parsed: Value = serde_json::from_str(&resp).unwrap();
683
684 assert_eq!(parsed["result"]["protocolVersion"], "2025-11-25");
685 assert!(parsed["result"]["capabilities"]["tools"].is_object());
686 assert!(parsed["result"]["capabilities"]["resources"].is_object());
687 assert_eq!(parsed["result"]["serverInfo"]["name"], "heartbit");
688 assert!(!sid.is_empty());
689 }
690
691 #[tokio::test]
692 async fn initialize_no_tools_capability_when_empty() {
693 let server = McpServer::new(McpServerConfig::default()).allow_unauthenticated();
694 let req = json!({
695 "jsonrpc": "2.0",
696 "method": "initialize",
697 "params": {},
698 "id": 1
699 });
700
701 let (resp, _) = server
702 .handle_request(&serde_json::to_string(&req).unwrap(), None)
703 .await;
704 let parsed: Value = serde_json::from_str(&resp).unwrap();
705
706 assert!(parsed["result"]["capabilities"]["tools"].is_null());
707 assert!(parsed["result"]["capabilities"]["resources"].is_null());
708 }
709
710 #[tokio::test]
713 async fn ping_returns_empty_result() {
714 let server = make_server();
715 let req = json!({"jsonrpc": "2.0", "method": "ping", "id": 42});
716 let (resp, _) = server
717 .handle_request(&serde_json::to_string(&req).unwrap(), None)
718 .await;
719 let parsed: Value = serde_json::from_str(&resp).unwrap();
720 assert_eq!(parsed["result"], json!({}));
721 assert_eq!(parsed["id"], 42);
722 }
723
724 #[tokio::test]
727 async fn tools_list_returns_all_tools() {
728 let server = make_server();
729 let req = json!({"jsonrpc": "2.0", "method": "tools/list", "id": 1});
730 let (resp, _) = server
731 .handle_request(&serde_json::to_string(&req).unwrap(), None)
732 .await;
733 let parsed: Value = serde_json::from_str(&resp).unwrap();
734
735 let tools = parsed["result"]["tools"].as_array().unwrap();
736 assert_eq!(tools.len(), 2);
737 assert_eq!(tools[0]["name"], "echo");
738 assert_eq!(tools[1]["name"], "fail");
739 assert!(tools[0]["inputSchema"]["properties"]["text"].is_object());
740 }
741
742 #[tokio::test]
743 async fn tools_list_empty_when_disabled() {
744 let server = McpServer::new(McpServerConfig {
745 expose_tools: false,
746 ..Default::default()
747 })
748 .allow_unauthenticated()
749 .with_tools(vec![Arc::new(EchoTool)]);
750
751 let req = json!({"jsonrpc": "2.0", "method": "tools/list", "id": 1});
752 let (resp, _) = server
753 .handle_request(&serde_json::to_string(&req).unwrap(), None)
754 .await;
755 let parsed: Value = serde_json::from_str(&resp).unwrap();
756 assert_eq!(parsed["result"]["tools"].as_array().unwrap().len(), 0);
757 }
758
759 #[tokio::test]
762 async fn tools_call_echo() {
763 let server = make_server();
764 let req = json!({
765 "jsonrpc": "2.0",
766 "method": "tools/call",
767 "params": {"name": "echo", "arguments": {"text": "hello world"}},
768 "id": 1
769 });
770 let (resp, _) = server
771 .handle_request(&serde_json::to_string(&req).unwrap(), None)
772 .await;
773 let parsed: Value = serde_json::from_str(&resp).unwrap();
774
775 let content = &parsed["result"]["content"][0];
776 assert_eq!(content["type"], "text");
777 assert_eq!(content["text"], "hello world");
778 assert_eq!(parsed["result"]["isError"], false);
779 }
780
781 #[tokio::test]
782 async fn tools_call_fail_returns_error_content() {
783 let server = make_server();
784 let req = json!({
785 "jsonrpc": "2.0",
786 "method": "tools/call",
787 "params": {"name": "fail", "arguments": {}},
788 "id": 1
789 });
790 let (resp, _) = server
791 .handle_request(&serde_json::to_string(&req).unwrap(), None)
792 .await;
793 let parsed: Value = serde_json::from_str(&resp).unwrap();
794
795 assert_eq!(parsed["result"]["isError"], true);
796 assert!(
797 parsed["result"]["content"][0]["text"]
798 .as_str()
799 .unwrap()
800 .contains("intentional failure")
801 );
802 }
803
804 #[tokio::test]
805 async fn tools_call_not_found() {
806 let server = make_server();
807 let req = json!({
808 "jsonrpc": "2.0",
809 "method": "tools/call",
810 "params": {"name": "nonexistent", "arguments": {}},
811 "id": 1
812 });
813 let (resp, _) = server
814 .handle_request(&serde_json::to_string(&req).unwrap(), None)
815 .await;
816 let parsed: Value = serde_json::from_str(&resp).unwrap();
817 assert!(
818 parsed["error"]["message"]
819 .as_str()
820 .unwrap()
821 .contains("not found")
822 );
823 }
824
825 #[tokio::test]
826 async fn tools_call_missing_params() {
827 let server = make_server();
828 let req = json!({
829 "jsonrpc": "2.0",
830 "method": "tools/call",
831 "id": 1
832 });
833 let (resp, _) = server
834 .handle_request(&serde_json::to_string(&req).unwrap(), None)
835 .await;
836 let parsed: Value = serde_json::from_str(&resp).unwrap();
837 assert!(parsed["error"].is_object());
838 }
839
840 #[tokio::test]
843 async fn resources_list_returns_all() {
844 let server = make_server();
845 let req = json!({"jsonrpc": "2.0", "method": "resources/list", "id": 1});
846 let (resp, _) = server
847 .handle_request(&serde_json::to_string(&req).unwrap(), None)
848 .await;
849 let parsed: Value = serde_json::from_str(&resp).unwrap();
850
851 let resources = parsed["result"]["resources"].as_array().unwrap();
852 assert_eq!(resources.len(), 2);
853 assert_eq!(resources[0]["uri"], "heartbit://tasks/123");
854 assert_eq!(resources[0]["name"], "task_123");
855 assert_eq!(resources[0]["mimeType"], "text/plain");
856 }
857
858 #[tokio::test]
859 async fn resources_list_empty_when_disabled() {
860 let server = McpServer::new(McpServerConfig {
861 expose_resources: false,
862 ..Default::default()
863 })
864 .allow_unauthenticated()
865 .with_resources(
866 vec![ServerResource {
867 uri: "test://x".into(),
868 name: "x".into(),
869 description: None,
870 mime_type: None,
871 }],
872 Arc::new(|_| Ok(vec![])),
873 );
874
875 let req = json!({"jsonrpc": "2.0", "method": "resources/list", "id": 1});
876 let (resp, _) = server
877 .handle_request(&serde_json::to_string(&req).unwrap(), None)
878 .await;
879 let parsed: Value = serde_json::from_str(&resp).unwrap();
880 assert_eq!(parsed["result"]["resources"].as_array().unwrap().len(), 0);
881 }
882
883 #[tokio::test]
886 async fn resources_read_success() {
887 let server = make_server();
888 let req = json!({
889 "jsonrpc": "2.0",
890 "method": "resources/read",
891 "params": {"uri": "heartbit://tasks/123"},
892 "id": 1
893 });
894 let (resp, _) = server
895 .handle_request(&serde_json::to_string(&req).unwrap(), None)
896 .await;
897 let parsed: Value = serde_json::from_str(&resp).unwrap();
898
899 let contents = parsed["result"]["contents"].as_array().unwrap();
900 assert_eq!(contents.len(), 1);
901 assert_eq!(contents[0]["uri"], "heartbit://tasks/123");
902 assert_eq!(contents[0]["text"], "Task completed!");
903 assert_eq!(contents[0]["mimeType"], "text/plain");
904 }
905
906 #[tokio::test]
907 async fn resources_read_not_found() {
908 let server = make_server();
909 let req = json!({
910 "jsonrpc": "2.0",
911 "method": "resources/read",
912 "params": {"uri": "heartbit://nonexistent"},
913 "id": 1
914 });
915 let (resp, _) = server
916 .handle_request(&serde_json::to_string(&req).unwrap(), None)
917 .await;
918 let parsed: Value = serde_json::from_str(&resp).unwrap();
919 assert!(
920 parsed["error"]["message"]
921 .as_str()
922 .unwrap()
923 .contains("not found")
924 );
925 }
926
927 #[tokio::test]
928 async fn resources_read_missing_uri() {
929 let server = make_server();
930 let req = json!({
931 "jsonrpc": "2.0",
932 "method": "resources/read",
933 "params": {},
934 "id": 1
935 });
936 let (resp, _) = server
937 .handle_request(&serde_json::to_string(&req).unwrap(), None)
938 .await;
939 let parsed: Value = serde_json::from_str(&resp).unwrap();
940 assert!(parsed["error"].is_object());
941 }
942
943 #[tokio::test]
946 async fn unknown_method_returns_error() {
947 let server = make_server();
948 let req = json!({"jsonrpc": "2.0", "method": "foobar", "id": 1});
949 let (resp, _) = server
950 .handle_request(&serde_json::to_string(&req).unwrap(), None)
951 .await;
952 let parsed: Value = serde_json::from_str(&resp).unwrap();
953 assert_eq!(parsed["error"]["code"], METHOD_NOT_FOUND);
954 }
955
956 #[tokio::test]
959 async fn notification_returns_empty_string() {
960 let server = make_server();
961 let req = json!({
962 "jsonrpc": "2.0",
963 "method": "notifications/initialized"
964 });
965 let (resp, _) = server
966 .handle_request(&serde_json::to_string(&req).unwrap(), None)
967 .await;
968 assert!(resp.is_empty());
969 }
970
971 #[tokio::test]
974 async fn invalid_json_returns_parse_error() {
975 let server = make_server();
976 let (resp, _) = server.handle_request("not json", None).await;
977 let parsed: Value = serde_json::from_str(&resp).unwrap();
978 assert_eq!(parsed["error"]["code"], -32700);
979 }
980
981 #[tokio::test]
984 async fn session_id_created_on_first_request() {
985 let server = make_server();
986 let req = json!({"jsonrpc": "2.0", "method": "ping", "id": 1});
987 let (_, sid1) = server
988 .handle_request(&serde_json::to_string(&req).unwrap(), None)
989 .await;
990 assert!(!sid1.is_empty());
991 let (_, sid2) = server
993 .handle_request(&serde_json::to_string(&req).unwrap(), Some(&sid1))
994 .await;
995 assert_eq!(sid1, sid2);
996 }
997
998 #[tokio::test]
999 async fn unknown_session_creates_new() {
1000 let server = make_server();
1001 let req = json!({"jsonrpc": "2.0", "method": "ping", "id": 1});
1002 let (_, sid) = server
1003 .handle_request(&serde_json::to_string(&req).unwrap(), Some("bad-session"))
1004 .await;
1005 assert_ne!(sid, "bad-session");
1006 }
1007
1008 #[test]
1011 fn tool_output_success_to_mcp() {
1012 let output = ToolOutput::success("hello");
1013 let mcp = tool_output_to_mcp(output);
1014 assert_eq!(mcp["content"][0]["type"], "text");
1015 assert_eq!(mcp["content"][0]["text"], "hello");
1016 assert_eq!(mcp["isError"], false);
1017 }
1018
1019 #[test]
1020 fn tool_output_error_to_mcp() {
1021 let output = ToolOutput::error("bad");
1022 let mcp = tool_output_to_mcp(output);
1023 assert_eq!(mcp["content"][0]["text"], "bad");
1024 assert_eq!(mcp["isError"], true);
1025 }
1026
1027 #[test]
1030 fn config_defaults() {
1031 let config = McpServerConfig::default();
1032 assert_eq!(config.name, "heartbit");
1033 assert!(config.expose_tools);
1034 assert!(config.expose_resources);
1035 assert!(!config.expose_prompts);
1036 }
1037
1038 #[test]
1041 fn server_resource_serde_roundtrip() {
1042 let r = ServerResource {
1043 uri: "heartbit://tasks/1".into(),
1044 name: "task_1".into(),
1045 description: Some("A task".into()),
1046 mime_type: Some("application/json".into()),
1047 };
1048 let json = serde_json::to_value(&r).unwrap();
1049 assert_eq!(json["uri"], "heartbit://tasks/1");
1050 assert_eq!(json["mimeType"], "application/json");
1051 let parsed: ServerResource = serde_json::from_value(json).unwrap();
1052 assert_eq!(parsed.name, "task_1");
1053 }
1054
1055 #[test]
1056 fn server_resource_minimal() {
1057 let json = json!({"uri": "test://x", "name": "x"});
1058 let r: ServerResource = serde_json::from_value(json).unwrap();
1059 assert!(r.description.is_none());
1060 assert!(r.mime_type.is_none());
1061 }
1062
1063 #[tokio::test]
1066 async fn auth_callback_rejects_when_returning_false() {
1067 let echo: Arc<dyn Tool> = Arc::new(EchoTool);
1068 let server = McpServer::new(McpServerConfig::default())
1069 .with_tools(vec![echo])
1070 .with_auth_callback(Arc::new(|_method, _sid, _auth| false));
1071
1072 let req = json!({
1073 "jsonrpc": "2.0",
1074 "method": "tools/call",
1075 "id": 7,
1076 "params": {"name": "echo", "arguments": {"text": "should not run"}}
1077 });
1078 let (resp, _sid) = server.handle_request(&req.to_string(), None).await;
1079 let parsed: Value = serde_json::from_str(&resp).unwrap();
1080 assert!(parsed["error"].is_object(), "expected error response");
1081 let code = parsed["error"]["code"].as_i64().unwrap_or_default();
1082 assert_eq!(code, UNAUTHORIZED, "expected 'Unauthorized' code");
1083 assert!(
1084 parsed["result"].is_null(),
1085 "result must be absent on auth failure"
1086 );
1087 }
1088
1089 #[tokio::test]
1092 async fn auth_callback_allows_when_returning_true() {
1093 let echo: Arc<dyn Tool> = Arc::new(EchoTool);
1094 let server = McpServer::new(McpServerConfig::default())
1095 .with_tools(vec![echo])
1096 .with_auth_callback(Arc::new(|_method, _sid, _auth| true));
1097
1098 let req = json!({
1099 "jsonrpc": "2.0",
1100 "method": "tools/call",
1101 "id": 8,
1102 "params": {"name": "echo", "arguments": {"text": "ok"}}
1103 });
1104 let (resp, _sid) = server.handle_request(&req.to_string(), None).await;
1105 let parsed: Value = serde_json::from_str(&resp).unwrap();
1106 assert!(parsed["error"].is_null(), "expected success: {parsed}");
1107 assert!(
1108 parsed["result"]["content"][0]["text"]
1109 .as_str()
1110 .unwrap_or_default()
1111 .contains("ok")
1112 );
1113 }
1114
1115 #[tokio::test]
1118 async fn session_map_is_bounded() {
1119 let server = McpServer::new(McpServerConfig::default());
1120 {
1122 let mut sessions = server.sessions.write();
1123 let now = Instant::now();
1124 for i in 0..MAX_SESSIONS {
1125 sessions.insert(format!("sid-{i}"), now);
1126 }
1127 assert_eq!(sessions.len(), MAX_SESSIONS);
1128 }
1129 let _ = server.ensure_session(None);
1131 let sessions = server.sessions.read();
1132 assert!(
1133 sessions.len() <= MAX_SESSIONS,
1134 "session map exceeded MAX_SESSIONS = {MAX_SESSIONS}: {}",
1135 sessions.len()
1136 );
1137 }
1138
1139 #[tokio::test]
1142 async fn no_auth_callback_and_no_allow_unauth_returns_unauthorized() {
1143 let echo: Arc<dyn Tool> = Arc::new(EchoTool);
1144 let server = McpServer::new(McpServerConfig::default()).with_tools(vec![echo]);
1146
1147 let req = json!({
1148 "jsonrpc": "2.0",
1149 "method": "tools/call",
1150 "id": 1,
1151 "params": {"name": "echo", "arguments": {"text": "should not run"}}
1152 });
1153 let (resp, _) = server.handle_request(&req.to_string(), None).await;
1154 let parsed: Value = serde_json::from_str(&resp).unwrap();
1155 assert!(
1156 parsed["error"].is_object(),
1157 "expected error when no auth installed; got: {parsed}"
1158 );
1159 assert_eq!(
1160 parsed["error"]["code"].as_i64().unwrap_or_default(),
1161 UNAUTHORIZED,
1162 "expected UNAUTHORIZED code; got: {parsed}"
1163 );
1164 }
1165
1166 #[tokio::test]
1169 async fn allow_unauthenticated_permits_requests() {
1170 let echo: Arc<dyn Tool> = Arc::new(EchoTool);
1171 let server = McpServer::new(McpServerConfig::default())
1172 .allow_unauthenticated()
1173 .with_tools(vec![echo]);
1174
1175 let req = json!({
1176 "jsonrpc": "2.0",
1177 "method": "tools/call",
1178 "id": 2,
1179 "params": {"name": "echo", "arguments": {"text": "allowed"}}
1180 });
1181 let (resp, _) = server.handle_request(&req.to_string(), None).await;
1182 let parsed: Value = serde_json::from_str(&resp).unwrap();
1183 assert!(parsed["error"].is_null(), "expected success; got: {parsed}");
1184 assert_eq!(
1185 parsed["result"]["content"][0]["text"]
1186 .as_str()
1187 .unwrap_or_default(),
1188 "allowed"
1189 );
1190 }
1191
1192 #[test]
1195 fn expired_session_is_evicted() {
1196 let server = McpServer::new(McpServerConfig::default());
1197 let expired_sid = "old-session-id";
1198 {
1200 let mut sessions = server.sessions.write();
1201 let expired_at = Instant::now()
1203 .checked_sub(SESSION_IDLE_TIMEOUT + Duration::from_secs(1))
1204 .expect("subtraction should not underflow on any sane platform");
1205 sessions.insert(expired_sid.to_string(), expired_at);
1206 }
1207 let new_sid = server.ensure_session(Some(expired_sid));
1209 assert_ne!(
1210 new_sid, expired_sid,
1211 "expired session must not be reused; got same sid back"
1212 );
1213 assert!(
1215 !server.sessions.read().contains_key(expired_sid),
1216 "expired session must be evicted from the map"
1217 );
1218 }
1219}