Skip to main content

nusy_codegraph/
mcp_bridge.rs

1//! Generic MCP-to-NATS bridge.
2//!
3//! `McpBridge` exposes any NATS service as Model Context Protocol (MCP) tools.
4//! It runs as a stdio MCP server (JSON-RPC 2.0 over stdin/stdout), routing
5//! `tools/list` and `tools/call` requests to the correct NATS services.
6//!
7//! # Protocol
8//!
9//! MCP uses JSON-RPC 2.0 over stdio. Each message is a newline-delimited JSON object.
10//!
11//! **tools/list flow:**
12//! 1. Client sends `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`
13//! 2. Bridge sends `{service}.cmd.tools` to each configured NATS service
14//! 3. Returns combined tool list from all services
15//!
16//! **tools/call flow:**
17//! 1. Client sends `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"code_search","arguments":{...}}}`
18//! 2. Bridge routes to `{service}.cmd.{tool_suffix}` via NATS request/reply
19//! 3. Returns NATS response as MCP content
20//!
21//! # Tool naming convention
22//!
23//! NATS handler commands use underscore-separated suffixes (e.g. `codegraph.cmd.search`).
24//! MCP tool names use the full `code_{command}` format (e.g. `code_search`).
25//! The bridge maps: `code_search` → `codegraph.cmd.search`
26//!
27//! # Binary
28//!
29//! ```bash
30//! nusy-mcp-bridge --nats nats://192.168.8.110:4222 --services codegraph
31//! ```
32
33use serde_json::Value;
34use std::io::{self, BufRead, Write};
35use std::time::Duration;
36
37/// Configuration for a single NATS service exposed via MCP.
38#[derive(Clone)]
39pub struct ServiceConfig {
40    /// NATS subject prefix (e.g. "codegraph.cmd").
41    pub subject_prefix: String,
42    /// MCP tool name prefix (e.g. "code_").
43    pub tool_prefix: String,
44}
45
46impl ServiceConfig {
47    pub fn new(subject_prefix: &str, tool_prefix: &str) -> Self {
48        ServiceConfig {
49            subject_prefix: subject_prefix.to_string(),
50            tool_prefix: tool_prefix.to_string(),
51        }
52    }
53}
54
55/// Generic MCP-to-NATS bridge.
56///
57/// Discovers tool schemas from NATS services and routes tool calls.
58pub struct McpBridge {
59    nats_url: String,
60    services: Vec<ServiceConfig>,
61    /// Request timeout in milliseconds.
62    timeout_ms: u64,
63}
64
65impl McpBridge {
66    pub fn new(nats_url: &str) -> Self {
67        McpBridge {
68            nats_url: nats_url.to_string(),
69            services: Vec::new(),
70            timeout_ms: 5000,
71        }
72    }
73
74    /// Add a NATS service to expose as MCP tools.
75    pub fn service(mut self, subject_prefix: &str, tool_prefix: &str) -> Self {
76        self.services
77            .push(ServiceConfig::new(subject_prefix, tool_prefix));
78        self
79    }
80
81    /// Set request timeout in milliseconds.
82    pub fn timeout_ms(mut self, ms: u64) -> Self {
83        self.timeout_ms = ms;
84        self
85    }
86
87    /// Run the bridge over stdio until EOF.
88    ///
89    /// Reads JSON-RPC requests from stdin, writes responses to stdout.
90    pub async fn run_stdio(self) -> Result<(), String> {
91        let client = async_nats::connect(&self.nats_url)
92            .await
93            .map_err(|e| format!("NATS connection failed: {e}"))?;
94
95        tracing::info!(url = %self.nats_url, services = self.services.len(), "MCP bridge ready");
96
97        let stdin = io::stdin();
98        let stdout = io::stdout();
99        let mut out = io::BufWriter::new(stdout.lock());
100
101        for line in stdin.lock().lines() {
102            let line = line.map_err(|e| format!("stdin read: {e}"))?;
103            let line = line.trim();
104            if line.is_empty() {
105                continue;
106            }
107
108            let request: Value = match serde_json::from_str(line) {
109                Ok(v) => v,
110                Err(e) => {
111                    let err_resp = json_error(Value::Null, -32700, &format!("parse error: {e}"));
112                    writeln!(
113                        out,
114                        "{}",
115                        serde_json::to_string(&err_resp).unwrap_or_default()
116                    )
117                    .ok();
118                    out.flush().ok();
119                    continue;
120                }
121            };
122
123            let id = request.get("id").cloned().unwrap_or(Value::Null);
124            let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("");
125            let params = request.get("params").cloned().unwrap_or(Value::Null);
126
127            let response = match method {
128                "initialize" => handle_initialize(id.clone()),
129                "tools/list" => self.handle_tools_list(&client, id.clone()).await,
130                "tools/call" => self.handle_tools_call(&client, id.clone(), &params).await,
131                other => json_error(id.clone(), -32601, &format!("method not found: {other}")),
132            };
133
134            writeln!(
135                out,
136                "{}",
137                serde_json::to_string(&response).unwrap_or_default()
138            )
139            .map_err(|e| format!("stdout write: {e}"))?;
140            out.flush().map_err(|e| format!("stdout flush: {e}"))?;
141        }
142
143        Ok(())
144    }
145
146    /// Fetch tool schemas from all configured NATS services.
147    async fn handle_tools_list(&self, client: &async_nats::Client, id: Value) -> Value {
148        let mut all_tools: Vec<Value> = Vec::new();
149
150        for svc in &self.services {
151            let tools_subject = format!("{}.tools", svc.subject_prefix);
152            let timeout = Duration::from_millis(self.timeout_ms);
153
154            match tokio::time::timeout(timeout, client.request(tools_subject, "{}".into())).await {
155                Ok(Ok(msg)) => {
156                    // Parse the ToolsResponse from noesis_service
157                    if let Ok(resp) = serde_json::from_slice::<Value>(&msg.payload)
158                        && let Some(tools) = resp.get("tools").and_then(|t| t.as_array())
159                    {
160                        for tool in tools {
161                            // Map tool name to MCP format: "code_search" → prefixed name
162                            let mcp_tool = serde_json::json!({
163                                "name": tool["name"],
164                                "description": tool["description"],
165                                "inputSchema": tool["input_schema"]
166                            });
167                            all_tools.push(mcp_tool);
168                        }
169                    }
170                }
171                Ok(Err(e)) => {
172                    tracing::warn!(subject = %svc.subject_prefix, error = %e, "tools fetch failed");
173                }
174                Err(_) => {
175                    tracing::warn!(subject = %svc.subject_prefix, "tools fetch timed out");
176                }
177            }
178        }
179
180        json_result(id, serde_json::json!({ "tools": all_tools }))
181    }
182
183    /// Route a tool call to the correct NATS service.
184    async fn handle_tools_call(
185        &self,
186        client: &async_nats::Client,
187        id: Value,
188        params: &Value,
189    ) -> Value {
190        let tool_name = match params.get("name").and_then(|n| n.as_str()) {
191            Some(n) => n,
192            None => return json_error(id, -32602, "missing 'name' in params"),
193        };
194        let arguments = params
195            .get("arguments")
196            .cloned()
197            .unwrap_or(serde_json::json!({}));
198
199        // Route: find which service owns this tool name
200        let route = self.route_tool(tool_name);
201        let (subject, _svc) = match route {
202            Some(r) => r,
203            None => {
204                return json_error(id, -32602, &format!("unknown tool: {tool_name}"));
205            }
206        };
207
208        let payload = serde_json::to_vec(&arguments).unwrap_or_else(|_| b"{}".to_vec());
209        let timeout = Duration::from_millis(self.timeout_ms);
210
211        match tokio::time::timeout(timeout, client.request(subject.clone(), payload.into())).await {
212            Ok(Ok(msg)) => {
213                let content_text = String::from_utf8_lossy(&msg.payload).to_string();
214                json_result(
215                    id,
216                    serde_json::json!({
217                        "content": [{"type": "text", "text": content_text}],
218                        "isError": false
219                    }),
220                )
221            }
222            Ok(Err(e)) => json_error(id, -32603, &format!("NATS error: {e}")),
223            Err(_) => json_error(id, -32603, &format!("timeout calling {subject}")),
224        }
225    }
226
227    /// Find the NATS subject for a given MCP tool name.
228    ///
229    /// Maps `code_search` → `(codegraph.cmd.search, &ServiceConfig)`.
230    fn route_tool(&self, tool_name: &str) -> Option<(String, &ServiceConfig)> {
231        for svc in &self.services {
232            if tool_name.starts_with(&svc.tool_prefix) {
233                let suffix = tool_name
234                    .strip_prefix(&svc.tool_prefix)
235                    .unwrap_or(tool_name);
236                let subject = format!("{}.{}", svc.subject_prefix, suffix);
237                return Some((subject, svc));
238            }
239        }
240        None
241    }
242}
243
244// ─── MCP protocol helpers ────────────────────────────────────────────────────
245
246fn handle_initialize(id: Value) -> Value {
247    json_result(
248        id,
249        serde_json::json!({
250            "protocolVersion": "2024-11-05",
251            "capabilities": {
252                "tools": {}
253            },
254            "serverInfo": {
255                "name": "nusy-mcp-bridge",
256                "version": env!("CARGO_PKG_VERSION")
257            }
258        }),
259    )
260}
261
262fn json_result(id: Value, result: Value) -> Value {
263    serde_json::json!({
264        "jsonrpc": "2.0",
265        "id": id,
266        "result": result
267    })
268}
269
270fn json_error(id: Value, code: i64, message: &str) -> Value {
271    serde_json::json!({
272        "jsonrpc": "2.0",
273        "id": id,
274        "error": {
275            "code": code,
276            "message": message
277        }
278    })
279}
280
281// ─── Tests ───────────────────────────────────────────────────────────────────
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn route_codegraph_tool() {
289        let bridge = McpBridge::new("nats://localhost:4222").service("codegraph.cmd", "code_");
290
291        let (subject, _) = bridge.route_tool("code_search").unwrap();
292        assert_eq!(subject, "codegraph.cmd.search");
293
294        let (subject, _) = bridge.route_tool("code_dependencies").unwrap();
295        assert_eq!(subject, "codegraph.cmd.dependencies");
296
297        let (subject, _) = bridge.route_tool("code_build").unwrap();
298        assert_eq!(subject, "codegraph.cmd.build");
299    }
300
301    #[test]
302    fn route_unknown_tool_returns_none() {
303        let bridge = McpBridge::new("nats://localhost:4222").service("codegraph.cmd", "code_");
304        assert!(bridge.route_tool("unknown_tool").is_none());
305    }
306
307    #[test]
308    fn route_multiple_services() {
309        let bridge = McpBridge::new("nats://localhost:4222")
310            .service("codegraph.cmd", "code_")
311            .service("kanban.cmd", "kb_");
312
313        let (subj, _) = bridge.route_tool("code_read").unwrap();
314        assert_eq!(subj, "codegraph.cmd.read");
315
316        let (subj, _) = bridge.route_tool("kb_create").unwrap();
317        assert_eq!(subj, "kanban.cmd.create");
318    }
319
320    #[test]
321    fn handle_initialize_returns_correct_version() {
322        let resp = handle_initialize(serde_json::json!(1));
323        assert_eq!(resp["jsonrpc"], "2.0");
324        assert_eq!(resp["id"], 1);
325        assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
326        assert!(resp["result"]["capabilities"]["tools"].is_object());
327    }
328
329    #[test]
330    fn json_result_shape() {
331        let r = json_result(serde_json::json!(42), serde_json::json!({"key": "val"}));
332        assert_eq!(r["jsonrpc"], "2.0");
333        assert_eq!(r["id"], 42);
334        assert_eq!(r["result"]["key"], "val");
335    }
336
337    #[test]
338    fn json_error_shape() {
339        let e = json_error(serde_json::json!(1), -32601, "not found");
340        assert_eq!(e["error"]["code"], -32601);
341        assert_eq!(e["error"]["message"], "not found");
342    }
343}