Skip to main content

recursive/
mcp_server.rs

1//! MCP server lifecycle management.
2//!
3//! This module provides [`McpServerManager`], which handles spawning MCP
4//! servers (stdio or HTTP+SSE), discovering their tools, and registering
5//! them into the agent's [`ToolRegistry`]. Each MCP tool is wrapped in a
6//! thin adapter that delegates execution to the corresponding server.
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────┐
12//! │              McpServerManager               │
13//! │  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
14//! │  │ Server A │  │ Server B │  │ Server C │  │
15//! │  │ (stdio)  │  │ (SSE)    │  │ (stdio)  │  │
16//! │  └────┬─────┘  └────┬─────┘  └────┬─────┘  │
17//! │       │              │              │        │
18//! │  ┌────▼──────────────▼──────────────▼────┐  │
19//! │  │          ToolRegistry                 │  │
20//! │  │  mcp__A__tool1  mcp__B__tool2  ...    │  │
21//! │  └───────────────────────────────────────┘  │
22//! └─────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::sync::Arc;
27
28use tokio::sync::Mutex;
29use tracing::{info, instrument};
30
31use crate::error::{Error, Result};
32use crate::llm::ToolSpec;
33use crate::mcp::{McpClient, McpServer, McpTool};
34use crate::tools::ToolRegistry;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
38
39// ---------------------------------------------------------------------------
40// JSON-RPC 2.0 protocol types (MCP server side)
41// ---------------------------------------------------------------------------
42
43/// A JSON-RPC 2.0 request.
44#[derive(Debug, Deserialize)]
45pub struct JsonRpcRequest {
46    pub jsonrpc: String,
47    #[serde(default)]
48    pub id: Option<Value>,
49    pub method: String,
50    #[serde(default)]
51    pub params: Value,
52}
53
54/// A JSON-RPC 2.0 response.
55#[derive(Debug, Serialize)]
56pub struct JsonRpcResponse {
57    pub jsonrpc: String,
58    pub id: Value,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub result: Option<Value>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub error: Option<JsonRpcError>,
63}
64
65/// A JSON-RPC 2.0 error object.
66#[derive(Debug, Serialize)]
67pub struct JsonRpcError {
68    pub code: i32,
69    pub message: String,
70}
71
72impl JsonRpcResponse {
73    /// Create a success response.
74    pub fn success(id: Value, result: Value) -> Self {
75        Self {
76            jsonrpc: "2.0".to_string(),
77            id,
78            result: Some(result),
79            error: None,
80        }
81    }
82
83    /// Create an error response.
84    pub fn error(id: Value, code: i32, message: String) -> Self {
85        Self {
86            jsonrpc: "2.0".to_string(),
87            id,
88            result: None,
89            error: Some(JsonRpcError { code, message }),
90        }
91    }
92
93    /// Create a parse error response (-32700).
94    pub fn parse_error() -> Self {
95        Self::error(Value::Null, -32700, "Parse error".to_string())
96    }
97
98    /// Create a method not found error response (-32601).
99    pub fn method_not_found(id: Value) -> Self {
100        Self::error(id, -32601, "Method not found".to_string())
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Dispatch
106// ---------------------------------------------------------------------------
107
108/// Dispatch a parsed JSON-RPC request and return a response.
109/// Returns `None` for notifications (no response needed).
110pub async fn dispatch_request(
111    request: &JsonRpcRequest,
112    tool_specs: &[ToolSpec],
113    tools: &ToolRegistry,
114) -> Option<JsonRpcResponse> {
115    match request.method.as_str() {
116        "initialize" => Some(handle_initialize(request)),
117        "notifications/initialized" => None,
118        "tools/list" => Some(handle_tools_list(request, tool_specs)),
119        "tools/call" => Some(handle_tools_call(request, tools).await),
120        _ => {
121            let id = request.id.clone().unwrap_or(Value::Null);
122            Some(JsonRpcResponse::method_not_found(id))
123        }
124    }
125}
126
127/// Handle `initialize` — return server capabilities and info.
128fn handle_initialize(request: &JsonRpcRequest) -> JsonRpcResponse {
129    let id = request.id.clone().unwrap_or(Value::Null);
130    JsonRpcResponse::success(
131        id,
132        serde_json::json!({
133            "protocolVersion": "2024-11-05",
134            "capabilities": {
135                "tools": {}
136            },
137            "serverInfo": {
138                "name": "recursive-mcp-server",
139                "version": "0.1.0"
140            }
141        }),
142    )
143}
144
145/// Handle `tools/list` — return the list of tool specs in MCP format.
146fn handle_tools_list(request: &JsonRpcRequest, tool_specs: &[ToolSpec]) -> JsonRpcResponse {
147    let id = request.id.clone().unwrap_or(Value::Null);
148    let tools: Vec<Value> = tool_specs
149        .iter()
150        .map(|spec| {
151            serde_json::json!({
152                "name": spec.name,
153                "description": spec.description,
154                "inputSchema": spec.parameters,
155            })
156        })
157        .collect();
158    JsonRpcResponse::success(id, serde_json::json!({ "tools": tools }))
159}
160
161/// Handle `tools/call` — execute a tool and return the result.
162async fn handle_tools_call(request: &JsonRpcRequest, tools: &ToolRegistry) -> JsonRpcResponse {
163    let id = request.id.clone().unwrap_or(Value::Null);
164    let tool_name = request
165        .params
166        .get("name")
167        .and_then(|v| v.as_str())
168        .unwrap_or("");
169    let arguments = request
170        .params
171        .get("arguments")
172        .cloned()
173        .unwrap_or(serde_json::json!({}));
174
175    if tool_name.is_empty() {
176        return JsonRpcResponse::error(id, -32602, "Missing tool name".to_string());
177    }
178
179    match tools.get(tool_name) {
180        Some(tool) => match tool.execute(arguments).await {
181            Ok(text) => JsonRpcResponse::success(
182                id,
183                serde_json::json!({
184                    "content": [{"type": "text", "text": text}]
185                }),
186            ),
187            Err(e) => JsonRpcResponse::success(
188                id,
189                serde_json::json!({
190                    "isError": true,
191                    "content": [{"type": "text", "text": e.to_string()}]
192                }),
193            ),
194        },
195        None => JsonRpcResponse::error(id, -32602, format!("Tool not found: {tool_name}")),
196    }
197}
198
199// ---------------------------------------------------------------------------
200// McpServerRunner — stdio transport loop
201// ---------------------------------------------------------------------------
202
203/// Runs the MCP server stdio loop: reads JSON-RPC from stdin, dispatches,
204/// and writes responses to stdout.
205pub struct McpServerRunner {
206    tool_specs: Vec<ToolSpec>,
207    tools: ToolRegistry,
208}
209
210impl McpServerRunner {
211    /// Create a new runner from a [`ToolRegistry`].
212    ///
213    /// The tool specs are extracted immediately so they can be served
214    /// without holding a borrow on the registry.
215    pub fn new(tools: ToolRegistry) -> Self {
216        let tool_specs = tools.specs();
217        Self { tool_specs, tools }
218    }
219
220    /// Run the stdio server loop until EOF on stdin.
221    pub async fn run(&self) -> Result<()> {
222        let stdin = tokio::io::stdin();
223        let stdout = tokio::io::stdout();
224        self.run_on(BufReader::new(stdin), stdout).await
225    }
226
227    /// Run the server loop on generic reader/writer (testable).
228    pub async fn run_on<R, W>(&self, reader: R, writer: W) -> Result<()>
229    where
230        R: tokio::io::AsyncBufRead + Unpin,
231        W: tokio::io::AsyncWrite + Unpin,
232    {
233        let mut lines = BufReader::new(reader).lines();
234        let mut out = writer;
235
236        while let Some(line) = lines.next_line().await? {
237            let line = line.trim().to_string();
238            if line.is_empty() {
239                continue;
240            }
241
242            // Parse the request
243            let request: JsonRpcRequest = match serde_json::from_str(&line) {
244                Ok(req) => req,
245                Err(_) => {
246                    let resp = JsonRpcResponse::parse_error();
247                    let json = serde_json::to_string(&resp)?;
248                    out.write_all(json.as_bytes()).await?;
249                    out.write_all(b"\n").await?;
250                    out.flush().await?;
251                    continue;
252                }
253            };
254
255            // Dispatch
256            if let Some(response) = dispatch_request(&request, &self.tool_specs, &self.tools).await
257            {
258                let json = serde_json::to_string(&response)?;
259                out.write_all(json.as_bytes()).await?;
260                out.write_all(b"\n").await?;
261                out.flush().await?;
262            }
263        }
264
265        Ok(())
266    }
267}
268
269/// Manages the lifecycle of one or more MCP servers and their tools.
270///
271/// Call [`McpServerManager::register_all`] to spawn every configured server,
272/// discover its tools, and register them into a [`ToolRegistry`]. The manager
273/// keeps the underlying [`McpClient`]s alive so they can handle tool calls.
274pub struct McpServerManager {
275    /// Configured servers.
276    servers: Vec<McpServer>,
277    /// Running clients, keyed by server name.
278    clients: Mutex<HashMap<String, Arc<Mutex<McpClient>>>>,
279}
280
281impl McpServerManager {
282    /// Create a new manager from a list of server configurations.
283    ///
284    /// The servers are not spawned until [`register_all`](Self::register_all) is called.
285    pub fn new(servers: Vec<McpServer>) -> Self {
286        Self {
287            servers,
288            clients: Mutex::new(HashMap::new()),
289        }
290    }
291
292    /// Spawn all configured servers, discover their tools, and register them
293    /// into the given [`ToolRegistry`].
294    ///
295    /// Returns a list of `(server_name, tool_count)` pairs for logging.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if any server fails to start or if tool discovery
300    /// fails. Servers are started sequentially; a failure stops the process.
301    #[instrument(skip_all, name = "mcp.register_all")]
302    pub async fn register_all(&self, registry: &mut ToolRegistry) -> Result<Vec<(String, usize)>> {
303        let mut results = Vec::new();
304
305        for server in &self.servers {
306            let name = server.name.clone();
307            info!(server = %name, "Starting MCP server");
308
309            let client = McpClient::spawn(server).await.map_err(|e| Error::Tool {
310                name: format!("mcp_server:{name}"),
311                call_id: None,
312                message: format!("Failed to start MCP server: {e}"),
313            })?;
314
315            let client = Arc::new(Mutex::new(client));
316
317            // Discover tools from this server.
318            let tool_specs = client
319                .lock()
320                .await
321                .list_tools()
322                .await
323                .map_err(|e| Error::Tool {
324                    name: format!("mcp_server:{name}"),
325                    call_id: None,
326                    message: format!("Failed to discover tools from MCP server: {e}"),
327                })?;
328
329            let tool_count = tool_specs.len();
330            info!(
331                server = %name,
332                count = tool_count,
333                "Discovered MCP tools"
334            );
335
336            // Wrap each tool spec in an McpTool and register it.
337            for spec in &tool_specs {
338                let tool = McpTool::new(client.clone(), spec.clone(), &name);
339                registry.register_mut(Arc::new(tool));
340                info!(
341                    server = %name,
342                    tool = %spec.name,
343                    "Registered MCP tool"
344                );
345            }
346
347            // Store the client so it stays alive.
348            self.clients.lock().await.insert(name.clone(), client);
349            results.push((name, tool_count));
350        }
351
352        Ok(results)
353    }
354
355    /// Shut down all running MCP clients.
356    ///
357    /// This drops the clients, which causes their background tasks (stdio
358    /// reader/writer, SSE listener) to be cancelled.
359    pub async fn shutdown(&self) {
360        let mut clients = self.clients.lock().await;
361        let names: Vec<String> = clients.keys().cloned().collect();
362        for name in &names {
363            info!(server = %name, "Shutting down MCP server");
364            clients.remove(name);
365        }
366    }
367
368    /// Return the number of currently running clients.
369    pub async fn running_count(&self) -> usize {
370        self.clients.lock().await.len()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::mcp::McpServer;
378
379    #[test]
380    fn success_response_has_result_and_no_error() {
381        // kills function-level replacement of success() or field swaps
382        use serde_json::{json, Value};
383        let r = JsonRpcResponse::success(Value::from(1i64), json!({"ok": true}));
384        assert!(r.result.is_some(), "success must have a result");
385        assert!(r.error.is_none(), "success must not have an error");
386        assert_eq!(r.jsonrpc, "2.0");
387    }
388
389    #[test]
390    fn parse_error_uses_negative_code() {
391        // kills `delete -` mutation in -32700
392        let r = JsonRpcResponse::parse_error();
393        let err = r.error.expect("parse_error must have an error");
394        assert_eq!(err.code, -32700, "parse error code must be -32700");
395    }
396
397    #[test]
398    fn method_not_found_uses_correct_negative_code() {
399        // kills `delete -` mutation in -32601
400        use serde_json::Value;
401        let r = JsonRpcResponse::method_not_found(Value::from(42i64));
402        let err = r.error.expect("method_not_found must have an error");
403        assert_eq!(err.code, -32601, "method not found code must be -32601");
404    }
405
406    /// Test that an empty server list produces no registrations.
407    #[tokio::test]
408    async fn empty_servers_registers_nothing() {
409        let manager = McpServerManager::new(vec![]);
410        let mut registry = ToolRegistry::local();
411        let results = manager.register_all(&mut registry).await.unwrap();
412        assert!(results.is_empty());
413        assert!(registry.names().is_empty());
414    }
415
416    /// Test that tool names are correctly namespaced.
417    #[test]
418    fn tool_name_format() {
419        let server = "my-server";
420        let tool = "Read";
421        let namespaced = format!("mcp__{}__{}", server, tool);
422        assert_eq!(namespaced, "mcp__my-server__Read");
423    }
424
425    /// Test that a server config with a URL creates an SSE-based client
426    /// (as opposed to stdio). This is a config-level test only.
427    #[test]
428    fn sse_server_config_detection() {
429        let server = McpServer {
430            name: "test-sse".into(),
431            command: String::new(),
432            args: vec![],
433            url: Some("http://localhost:8080/sse".into()),
434            env: None,
435        };
436        // The McpClient::spawn method checks server.url.is_some()
437        // to decide transport. We verify the config is wired correctly.
438        assert!(server.url.is_some());
439        assert!(server.command.is_empty());
440    }
441
442    /// Test that a server config with a command creates a stdio-based client.
443    #[test]
444    fn stdio_server_config_detection() {
445        let server = McpServer {
446            name: "test-stdio".into(),
447            command: "echo".into(),
448            args: vec!["hello".into()],
449            url: None,
450            env: None,
451        };
452        assert!(!server.command.is_empty());
453        assert!(server.url.is_none());
454    }
455
456    #[test]
457    fn error_response_has_error_and_no_result() {
458        // kills field swap mutations in JsonRpcResponse::error()
459        let r = JsonRpcResponse::error(Value::from(1i64), -32600, "bad request".to_string());
460        assert!(r.error.is_some(), "error response must have error field");
461        assert!(
462            r.result.is_none(),
463            "error response must not have result field"
464        );
465        let err = r.error.unwrap();
466        assert_eq!(err.code, -32600);
467        assert_eq!(err.message, "bad request");
468    }
469
470    #[tokio::test]
471    async fn dispatch_notifications_returns_none() {
472        // kills `=> None` → `=> Some(...)` mutation for notifications/initialized
473        let reg = ToolRegistry::local();
474        let specs = reg.specs();
475        let request = JsonRpcRequest {
476            jsonrpc: "2.0".to_string(),
477            id: None,
478            method: "notifications/initialized".to_string(),
479            params: Value::Null,
480        };
481        let result = dispatch_request(&request, &specs, &reg).await;
482        assert!(
483            result.is_none(),
484            "notifications/initialized must return None (no response)"
485        );
486    }
487
488    #[tokio::test]
489    async fn dispatch_unknown_method_returns_method_not_found() {
490        // kills `_ => Some(JsonRpcResponse::method_not_found(...))` mutation
491        let reg = ToolRegistry::local();
492        let specs = reg.specs();
493        let request = JsonRpcRequest {
494            jsonrpc: "2.0".to_string(),
495            id: Some(Value::from(99i64)),
496            method: "totally/unknown".to_string(),
497            params: Value::Null,
498        };
499        let result = dispatch_request(&request, &specs, &reg).await;
500        assert!(result.is_some(), "unknown method must return Some");
501        let r = result.unwrap();
502        let err = r.error.expect("unknown method must return error");
503        assert_eq!(err.code, -32601, "must use -32601 for method not found");
504    }
505
506    #[tokio::test]
507    async fn dispatch_initialize_returns_protocol_version() {
508        // kills `"initialize" => Some(handle_initialize(...))` removal mutation
509        let reg = ToolRegistry::local();
510        let specs = reg.specs();
511        let request = JsonRpcRequest {
512            jsonrpc: "2.0".to_string(),
513            id: Some(Value::from(1i64)),
514            method: "initialize".to_string(),
515            params: Value::Null,
516        };
517        let result = dispatch_request(&request, &specs, &reg).await;
518        assert!(result.is_some(), "initialize must return Some response");
519        let r = result.unwrap();
520        assert!(r.error.is_none(), "initialize must not return error");
521        let res = r.result.expect("initialize must have result");
522        assert_eq!(
523            res["protocolVersion"], "2024-11-05",
524            "initialize result must contain protocolVersion"
525        );
526    }
527
528    #[tokio::test]
529    async fn dispatch_tools_list_returns_tool_names() {
530        // kills `"tools/list" => Some(handle_tools_list(...))` removal mutation
531        // ToolRegistry::local() is empty; build a real registry via the standard builder.
532        let tmp = tempfile::TempDir::new().unwrap();
533        let reg = crate::tools::build_standard_tools(tmp.path(), &[], 30);
534        let specs = reg.specs();
535        let request = JsonRpcRequest {
536            jsonrpc: "2.0".to_string(),
537            id: Some(Value::from(2i64)),
538            method: "tools/list".to_string(),
539            params: Value::Null,
540        };
541        let result = dispatch_request(&request, &specs, &reg).await;
542        assert!(result.is_some(), "tools/list must return Some response");
543        let r = result.unwrap();
544        let res = r.result.expect("tools/list must have result");
545        let tools = res["tools"]
546            .as_array()
547            .expect("result must have tools array");
548        assert!(
549            !tools.is_empty(),
550            "tools/list must return at least one tool"
551        );
552        let has_read = tools.iter().any(|t| t["name"].as_str() == Some("Read"));
553        assert!(has_read, "tools/list must include 'Read' tool");
554    }
555
556    #[tokio::test]
557    async fn handle_tools_call_missing_name_returns_error() {
558        // kills `if tool_name.is_empty()` guard removal mutation
559        let reg = ToolRegistry::local();
560        let specs = reg.specs();
561        let request = JsonRpcRequest {
562            jsonrpc: "2.0".to_string(),
563            id: Some(Value::from(3i64)),
564            method: "tools/call".to_string(),
565            params: serde_json::json!({ "arguments": {} }), // no "name" field
566        };
567        let result = dispatch_request(&request, &specs, &reg).await;
568        let r = result.expect("tools/call must return Some");
569        let err = r.error.expect("missing tool name must return error");
570        assert_eq!(err.code, -32602, "missing name must use -32602");
571    }
572
573    #[tokio::test]
574    async fn handle_tools_call_unknown_tool_uses_negative_error_code() {
575        // kills `delete - in handle_tools_call` mutation (changing -32602 → 32602)
576        let tmp = tempfile::TempDir::new().unwrap();
577        let reg = crate::tools::build_standard_tools(tmp.path(), &[], 30);
578        let specs = reg.specs();
579        let request = JsonRpcRequest {
580            jsonrpc: "2.0".to_string(),
581            id: Some(Value::from(4i64)),
582            method: "tools/call".to_string(),
583            params: serde_json::json!({ "name": "no_such_tool_xyz", "arguments": {} }),
584        };
585        let result = dispatch_request(&request, &specs, &reg).await;
586        let r = result.expect("tools/call must return Some for unknown tool");
587        let err = r.error.expect("unknown tool must return an error response");
588        assert_eq!(
589            err.code, -32602,
590            "unknown tool error code must be -32602 (negative); got: {}",
591            err.code
592        );
593        assert!(
594            err.message.contains("no_such_tool_xyz"),
595            "error message must mention the tool name; got: {}",
596            err.message
597        );
598    }
599}