Skip to main content

heartbit_core/tool/
mcp_server.rs

1//! MCP server — exposes heartbit tools/resources to external MCP clients.
2//!
3//! Designed to be mounted on an existing Axum router via `handle_request()`. See
4//! [`McpServer`] for security caveats; the caller is responsible for authentication.
5
6use 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// --- JSON-RPC types ---
20
21#[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
70// JSON-RPC error codes
71const METHOD_NOT_FOUND: i64 = -32601;
72const INVALID_PARAMS: i64 = -32602;
73const INTERNAL_ERROR: i64 = -32603;
74
75// --- MCP Server ---
76
77/// Configuration for the MCP server.
78#[derive(Debug, Clone)]
79pub struct McpServerConfig {
80    /// Server name reported in the `initialize` response.
81    pub name: String,
82    /// Server version reported in the `initialize` response.
83    pub version: String,
84    /// Expose registered tools via `tools/list` and `tools/call`.
85    pub expose_tools: bool,
86    /// Expose registered resources via `resources/list` and `resources/read`.
87    pub expose_resources: bool,
88    /// Expose prompts via `prompts/list` and `prompts/get` (currently a no-op).
89    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/// A resource exposed by the MCP server.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct ServerResource {
108    /// Resource URI (e.g. `file:///docs/readme.md`).
109    pub uri: String,
110    /// Display name for the resource.
111    pub name: String,
112    /// Optional description.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub description: Option<String>,
115    /// Optional MIME type hint.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub mime_type: Option<String>,
118}
119
120/// Callback to read resource content. Returns `(mime_type, text)`.
121pub type ResourceReader =
122    Arc<dyn Fn(&str) -> Result<Vec<(Option<String>, String)>, Error> + Send + Sync>;
123
124/// Callback to authorize a JSON-RPC call. Receives the parsed `method`, the
125/// session id (if the client provided one), and any HTTP-level credentials
126/// the integrator wants to forward (typically a Bearer token extracted from
127/// the `Authorization` header upstream). Return `true` to allow, `false` to
128/// reject with HTTP-401-equivalent JSON-RPC error.
129pub type AuthCallback = Arc<dyn Fn(&str, Option<&str>, Option<&str>) -> bool + Send + Sync>;
130
131/// JSON-RPC error code used when [`McpServer`] rejects a call via its
132/// `auth_callback`. Mirrors HTTP 401 semantics in the MCP transport layer.
133const UNAUTHORIZED: i64 = -32001;
134
135/// Maximum number of MCP sessions retained simultaneously.
136///
137/// SECURITY (F-MCP-3): without a cap, a hostile/unauth client can issue
138/// distinct `Mcp-Session-Id` values indefinitely and balloon the in-memory
139/// `sessions` map. The cap drops older entries (insertion order best-effort)
140/// once the limit is reached, keeping memory bounded.
141const MAX_SESSIONS: usize = 256;
142
143/// Idle timeout after which an inactive session is evicted.
144///
145/// SECURITY (F-MCP-3): sessions that have not sent a request within this
146/// window are considered stale and removed from the session map, limiting
147/// the blast radius of session-fixation or session-hijack attacks and
148/// keeping memory bounded independently of the MAX_SESSIONS cap.
149const SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); // 30 minutes
150
151/// MCP server that handles JSON-RPC requests.
152///
153/// Exposes heartbit tools, resources, and prompts to external MCP clients.
154/// Designed to be mounted on an existing Axum router via `handle_request()`.
155///
156/// # Security — Fail-Closed Auth (F-MCP-3)
157///
158/// **This server fails closed: without authentication, every JSON-RPC request
159/// returns an `Unauthorized` error by default.** To permit unauthenticated
160/// access (e.g., for single-process local testing), call
161/// [`McpServer::allow_unauthenticated`] explicitly on the builder.
162///
163/// For production deployments reachable over a network, the integrator MUST
164/// either:
165///
166/// 1. Wire an [`AuthCallback`] via [`McpServer::with_auth_callback`] which is
167///    consulted on every JSON-RPC call and passes the HTTP-level credentials
168///    extracted by the enclosing handler, **or**
169/// 2. Mount the server behind an HTTP middleware that rejects unauth'd
170///    requests *before* they reach `handle_request` **and** call
171///    [`McpServer::allow_unauthenticated`] to signal that outer-layer auth
172///    is the only gate.
173///
174/// Without one of these, any network-reachable client is rejected with
175/// UNAUTHORIZED (-32001).
176pub struct McpServer {
177    config: McpServerConfig,
178    tools: Vec<Arc<dyn Tool>>,
179    resources: Vec<ServerResource>,
180    resource_reader: Option<ResourceReader>,
181    /// Session map: session ID → last-active `Instant`.
182    ///
183    /// SECURITY (F-MCP-3): bounded by `MAX_SESSIONS`; entries older than
184    /// `SESSION_IDLE_TIMEOUT` are evicted to prevent stale-session accumulation.
185    sessions: parking_lot::RwLock<HashMap<String, Instant>>,
186    auth_callback: Option<AuthCallback>,
187    /// SECURITY (F-MCP-3): when `false` (the default) the server rejects any
188    /// request that arrives with no `auth_callback` installed. Operators who
189    /// deliberately want open access (local testing, inner-loop dev) must set
190    /// this flag explicitly via [`McpServer::allow_unauthenticated`].
191    allow_unauthenticated: bool,
192}
193
194impl McpServer {
195    /// Create a fresh server with the given config. No tools/resources are registered until
196    /// [`McpServer::with_tools`] / [`McpServer::with_resources`] are called.
197    ///
198    /// # Security
199    ///
200    /// By default the server fails **closed**: every request returns
201    /// `Unauthorized` until either an [`AuthCallback`] is installed (via
202    /// [`McpServer::with_auth_callback`]) or unauthenticated access is
203    /// explicitly opted into (via [`McpServer::allow_unauthenticated`]).
204    pub fn new(config: McpServerConfig) -> Self {
205        Self {
206            config,
207            tools: Vec::new(),
208            resources: Vec::new(),
209            resource_reader: None,
210            // parking_lot adopted on this hot path (every MCP request);
211            // see T2 in `tasks/performance-audit-heartbit-core-2026-05-06.md`.
212            sessions: parking_lot::RwLock::new(HashMap::new()),
213            auth_callback: None,
214            allow_unauthenticated: false,
215        }
216    }
217
218    /// Register tools to expose via MCP.
219    pub fn with_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
220        self.tools = tools;
221        self
222    }
223
224    /// Register resources to expose via MCP.
225    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    /// Install an authentication callback (`fn(method, session_id, auth_header) -> bool`).
236    ///
237    /// SECURITY (F-MCP-3): when set, every `handle_request` call is gated by
238    /// this callback. The integrator should extract the relevant credentials
239    /// from the HTTP layer (e.g. Authorization header) and pass them through
240    /// [`McpServer::handle_request_with_auth`].
241    pub fn with_auth_callback(mut self, callback: AuthCallback) -> Self {
242        self.auth_callback = Some(callback);
243        self
244    }
245
246    /// Opt into permitting requests without authentication.
247    ///
248    /// SECURITY (F-MCP-3): the default is **fail-closed** — any request that
249    /// arrives when no [`AuthCallback`] is installed is rejected with
250    /// `Unauthorized`. Call this method *only* when:
251    ///
252    /// - The process is single-tenant and runs locally (e.g. integration tests,
253    ///   local CLI dev loop), **or**
254    /// - An outer HTTP-middleware layer already enforces authentication and the
255    ///   inner MCP layer should treat all forwarded requests as pre-authorized.
256    ///
257    /// Do **not** call this on a server that is directly reachable over an
258    /// untrusted network without an [`AuthCallback`].
259    pub fn allow_unauthenticated(mut self) -> Self {
260        self.allow_unauthenticated = true;
261        self
262    }
263
264    /// Create or validate a session ID, refreshing the last-active timestamp.
265    ///
266    /// SECURITY (F-MCP-3): evicts sessions idle longer than
267    /// [`SESSION_IDLE_TIMEOUT`] before inserting a new one, so both the
268    /// count cap and the idle-timeout cap apply independently.
269    fn ensure_session(&self, session_id: Option<&str>) -> String {
270        let now = Instant::now();
271
272        // Fast path: re-use an existing, non-expired session.
273        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                // Session expired — fall through to create a fresh one.
281                sessions.remove(sid);
282            }
283        }
284
285        let new_sid = Uuid::new_v4().to_string();
286        let mut sessions = self.sessions.write();
287
288        // SECURITY (F-MCP-3): evict all TTL-expired sessions first.
289        sessions.retain(|_, last_active| now.duration_since(*last_active) < SESSION_IDLE_TIMEOUT);
290
291        // Then apply the count cap as a last-resort backstop.
292        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    /// Handle a JSON-RPC request and return a response with session ID.
303    ///
304    /// If an [`AuthCallback`] is installed, this method calls it without an
305    /// auth header (`None`). Use [`McpServer::handle_request_with_auth`] when
306    /// the integrator wants to forward HTTP-level credentials.
307    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    /// Handle a JSON-RPC request with an explicit auth header (e.g. extracted
312    /// from the upstream HTTP Authorization header). When an
313    /// [`AuthCallback`] is installed, it receives this value.
314    ///
315    /// SECURITY (F-MCP-3): this method fails **closed**. If no
316    /// [`AuthCallback`] is installed and [`McpServer::allow_unauthenticated`]
317    /// was not called, every request is rejected with `Unauthorized` regardless
318    /// of method. Install an auth callback or explicitly opt into open access
319    /// via the builder before wiring this server into a network-accessible
320    /// endpoint.
321    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                // SECURITY (F-MCP-3): authentication gate — fail closed.
332                // Decision matrix:
333                //   auth_callback=Some(cb) → call cb; reject if it returns false
334                //   auth_callback=None + allow_unauthenticated=true  → allow
335                //   auth_callback=None + allow_unauthenticated=false → reject
336                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                // Notifications don't require a response, but we return empty for HTTP.
368                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        // TODO(phase-1): derive ExecutionContext from MCP session / clientInfo when
463        // multi-tenant MCP integration lands (likely heartbit-ghost Phase 1). Default
464        // placeholder is safe: pre-trait-change there was no context at all, so blast
465        // radius is unchanged.
466        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        // Validate the URI exists
521        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            // Tests run in-process — explicitly opt into unauthenticated access
635            // so the default fail-closed posture does not break the test suite.
636            .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    // --- Initialize ---
664
665    #[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    // --- Ping ---
711
712    #[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    // --- Tools/list ---
725
726    #[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    // --- Tools/call ---
760
761    #[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    // --- Resources/list ---
841
842    #[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    // --- Resources/read ---
884
885    #[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    // --- Method not found ---
944
945    #[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    // --- Notifications ---
957
958    #[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    // --- Parse error ---
972
973    #[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    // --- Session management ---
982
983    #[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        // Reuse the session
992        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    // --- tool_output_to_mcp ---
1009
1010    #[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    // --- Config defaults ---
1028
1029    #[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    // --- ServerResource serde ---
1039
1040    #[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    /// SECURITY (F-MCP-3): an installed `auth_callback` returning `false` must
1064    /// produce a JSON-RPC unauthorized response and **not** route to the tool.
1065    #[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    /// SECURITY (F-MCP-3): an `auth_callback` returning `true` allows the call
1090    /// to route normally. Confirms we did not introduce a regression.
1091    #[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    /// SECURITY (F-MCP-3): the session map MUST be bounded so unauth'd clients
1116    /// cannot exhaust memory by minting fresh `Mcp-Session-Id` values.
1117    #[tokio::test]
1118    async fn session_map_is_bounded() {
1119        let server = McpServer::new(McpServerConfig::default());
1120        // Force the cap to fill — we do this by manipulating the lock directly.
1121        {
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        // Issue another `ensure_session` with a new id — should evict and stay bounded.
1130        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    /// SECURITY (F-MCP-3): a server with no auth_callback and no
1140    /// allow_unauthenticated must return UNAUTHORIZED for any request.
1141    #[tokio::test]
1142    async fn no_auth_callback_and_no_allow_unauth_returns_unauthorized() {
1143        let echo: Arc<dyn Tool> = Arc::new(EchoTool);
1144        // Default server: auth_callback=None, allow_unauthenticated=false → fail closed.
1145        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    /// SECURITY (F-MCP-3): allow_unauthenticated() permits requests without
1167    /// an auth_callback. This is the opt-in escape hatch for local/test usage.
1168    #[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    /// SECURITY (F-MCP-3): sessions past their idle TTL must be evicted so
1193    /// they cannot be reused after expiry (session fixation / long-lived orphan).
1194    #[test]
1195    fn expired_session_is_evicted() {
1196        let server = McpServer::new(McpServerConfig::default());
1197        let expired_sid = "old-session-id";
1198        // Inject a session whose last-active timestamp is far in the past.
1199        {
1200            let mut sessions = server.sessions.write();
1201            // Simulate a session created SESSION_IDLE_TIMEOUT + 1s ago.
1202            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        // ensure_session with the expired id must produce a *new* id.
1208        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        // The expired entry must no longer be in the map.
1214        assert!(
1215            !server.sessions.read().contains_key(expired_sid),
1216            "expired session must be evicted from the map"
1217        );
1218    }
1219}