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::mcp::{McpClient, McpServer, McpTool};
33use crate::tools::ToolRegistry;
34use crate::llm::ToolSpec;
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(
196            id,
197            -32602,
198            format!("Tool not found: {tool_name}"),
199        ),
200    }
201}
202
203// ---------------------------------------------------------------------------
204// McpServerRunner — stdio transport loop
205// ---------------------------------------------------------------------------
206
207/// Runs the MCP server stdio loop: reads JSON-RPC from stdin, dispatches,
208/// and writes responses to stdout.
209pub struct McpServerRunner {
210    tool_specs: Vec<ToolSpec>,
211    tools: ToolRegistry,
212}
213
214impl McpServerRunner {
215    /// Create a new runner from a [`ToolRegistry`].
216    ///
217    /// The tool specs are extracted immediately so they can be served
218    /// without holding a borrow on the registry.
219    pub fn new(tools: ToolRegistry) -> Self {
220        let tool_specs = tools.specs();
221        Self { tool_specs, tools }
222    }
223
224    /// Run the stdio server loop until EOF on stdin.
225    pub async fn run(&self) -> Result<()> {
226        let stdin = tokio::io::stdin();
227        let stdout = tokio::io::stdout();
228        self.run_on(BufReader::new(stdin), stdout).await
229    }
230
231    /// Run the server loop on generic reader/writer (testable).
232    pub async fn run_on<R, W>(&self, reader: R, writer: W) -> Result<()>
233    where
234        R: tokio::io::AsyncBufRead + Unpin,
235        W: tokio::io::AsyncWrite + Unpin,
236    {
237        let mut lines = BufReader::new(reader).lines();
238        let mut out = writer;
239
240        while let Some(line) = lines.next_line().await? {
241            let line = line.trim().to_string();
242            if line.is_empty() {
243                continue;
244            }
245
246            // Parse the request
247            let request: JsonRpcRequest = match serde_json::from_str(&line) {
248                Ok(req) => req,
249                Err(_) => {
250                    let resp = JsonRpcResponse::parse_error();
251                    let json = serde_json::to_string(&resp)?;
252                    out.write_all(json.as_bytes()).await?;
253                    out.write_all(b"\n").await?;
254                    out.flush().await?;
255                    continue;
256                }
257            };
258
259            // Dispatch
260            if let Some(response) = dispatch_request(&request, &self.tool_specs, &self.tools).await {
261                let json = serde_json::to_string(&response)?;
262                out.write_all(json.as_bytes()).await?;
263                out.write_all(b"\n").await?;
264                out.flush().await?;
265            }
266        }
267
268        Ok(())
269    }
270}
271
272/// Manages the lifecycle of one or more MCP servers and their tools.
273///
274/// Call [`McpServerManager::register_all`] to spawn every configured server,
275/// discover its tools, and register them into a [`ToolRegistry`]. The manager
276/// keeps the underlying [`McpClient`]s alive so they can handle tool calls.
277pub struct McpServerManager {
278    /// Configured servers.
279    servers: Vec<McpServer>,
280    /// Running clients, keyed by server name.
281    clients: Mutex<HashMap<String, Arc<Mutex<McpClient>>>>,
282}
283
284impl McpServerManager {
285    /// Create a new manager from a list of server configurations.
286    ///
287    /// The servers are not spawned until [`register_all`](Self::register_all) is called.
288    pub fn new(servers: Vec<McpServer>) -> Self {
289        Self {
290            servers,
291            clients: Mutex::new(HashMap::new()),
292        }
293    }
294
295    /// Spawn all configured servers, discover their tools, and register them
296    /// into the given [`ToolRegistry`].
297    ///
298    /// Returns a list of `(server_name, tool_count)` pairs for logging.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if any server fails to start or if tool discovery
303    /// fails. Servers are started sequentially; a failure stops the process.
304    #[instrument(skip_all, name = "mcp.register_all")]
305    pub async fn register_all(&self, registry: &mut ToolRegistry) -> Result<Vec<(String, usize)>> {
306        let mut results = Vec::new();
307
308        for server in &self.servers {
309            let name = server.name.clone();
310            info!(server = %name, "Starting MCP server");
311
312            let client = McpClient::spawn(server).await.map_err(|e| {
313                Error::Tool {
314                    name: format!("mcp_server:{name}"),
315                    message: format!("Failed to start MCP server: {e}"),
316                }
317            })?;
318
319            let client = Arc::new(Mutex::new(client));
320
321            // Discover tools from this server.
322            let tool_specs = client
323                .lock()
324                .await
325                .list_tools()
326                .await
327                .map_err(|e| Error::Tool {
328                    name: format!("mcp_server:{name}"),
329                    message: format!("Failed to discover tools from MCP server: {e}"),
330                })?;
331
332            let tool_count = tool_specs.len();
333            info!(
334                server = %name,
335                count = tool_count,
336                "Discovered MCP tools"
337            );
338
339            // Wrap each tool spec in an McpTool and register it.
340            for spec in &tool_specs {
341                let tool = McpTool::new(client.clone(), spec.clone(), &name);
342                registry.register_mut(Arc::new(tool));
343                info!(
344                    server = %name,
345                    tool = %spec.name,
346                    "Registered MCP tool"
347                );
348            }
349
350            // Store the client so it stays alive.
351            self.clients.lock().await.insert(name.clone(), client);
352            results.push((name, tool_count));
353        }
354
355        Ok(results)
356    }
357
358    /// Shut down all running MCP clients.
359    ///
360    /// This drops the clients, which causes their background tasks (stdio
361    /// reader/writer, SSE listener) to be cancelled.
362    pub async fn shutdown(&self) {
363        let mut clients = self.clients.lock().await;
364        let names: Vec<String> = clients.keys().cloned().collect();
365        for name in &names {
366            info!(server = %name, "Shutting down MCP server");
367            clients.remove(name);
368        }
369    }
370
371    /// Return the number of currently running clients.
372    pub async fn running_count(&self) -> usize {
373        self.clients.lock().await.len()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::mcp::McpServer;
381
382    /// Test that an empty server list produces no registrations.
383    #[tokio::test]
384    async fn empty_servers_registers_nothing() {
385        let manager = McpServerManager::new(vec![]);
386        let mut registry = ToolRegistry::local();
387        let results = manager.register_all(&mut registry).await.unwrap();
388        assert!(results.is_empty());
389        assert!(registry.names().is_empty());
390    }
391
392    /// Test that tool names are correctly namespaced.
393    #[test]
394    fn tool_name_format() {
395        let server = "my-server";
396        let tool = "read_file";
397        let namespaced = format!("mcp__{}__{}", server, tool);
398        assert_eq!(namespaced, "mcp__my-server__read_file");
399    }
400
401    /// Test that a server config with a URL creates an SSE-based client
402    /// (as opposed to stdio). This is a config-level test only.
403    #[test]
404    fn sse_server_config_detection() {
405        let server = McpServer {
406            name: "test-sse".into(),
407            command: String::new(),
408            args: vec![],
409            url: Some("http://localhost:8080/sse".into()),
410        };
411        // The McpClient::spawn method checks server.url.is_some()
412        // to decide transport. We verify the config is wired correctly.
413        assert!(server.url.is_some());
414        assert!(server.command.is_empty());
415    }
416
417    /// Test that a server config with a command creates a stdio-based client.
418    #[test]
419    fn stdio_server_config_detection() {
420        let server = McpServer {
421            name: "test-stdio".into(),
422            command: "echo".into(),
423            args: vec!["hello".into()],
424            url: None,
425        };
426        assert!(!server.command.is_empty());
427        assert!(server.url.is_none());
428    }
429}