Skip to main content

rustapi_mcp/
server.rs

1//! The main `McpServer` type and lifecycle.
2
3use crate::config::McpConfig;
4use crate::discovery;
5use crate::error::{McpError, Result};
6use crate::types::{McpCapability, McpTool};
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full};
9use hyper::body::Incoming;
10use hyper::server::conn::http1;
11use hyper::Response;
12use hyper_util::rt::TokioIo;
13use rustapi_core::RustApi;
14use rustapi_openapi::OpenApiSpec;
15use std::collections::HashMap;
16use std::convert::Infallible;
17use std::future::Future;
18use std::net::SocketAddr;
19use std::sync::Arc;
20use tokio::net::TcpListener;
21use tracing::{error, info};
22
23/// The main handle for the MCP integration.
24///
25/// `McpServer` can be attached to a `RustApi` instance (via its OpenAPI spec)
26/// to automatically discover tools. Tool invocations (in later milestones)
27/// will be driven through the normal RustAPI handler pipeline.
28#[derive(Debug, Clone)]
29pub struct McpServer {
30    config: Arc<McpConfig>,
31    /// Attached OpenAPI spec used for tool discovery.
32    openapi: Option<OpenApiSpec>,
33    /// Base URL of the main RustAPI HTTP server (for proxy-style tool invocation over localhost).
34    /// Example: "http://127.0.0.1:8080"
35    http_base: Option<String>,
36    /// Internal mapping from tool name (as advertised to MCP) to the original HTTP route info.
37    /// Used to reconstruct the correct request when a tool is called.
38    tool_map: HashMap<String, ToolExecutionInfo>,
39}
40
41/// Internal info needed to turn an MCP tool/call into an actual HTTP request
42/// against the main API.
43#[derive(Debug, Clone)]
44struct ToolExecutionInfo {
45    /// Original path template, e.g. "/users/{id}"
46    path_template: String,
47    /// HTTP method as string, e.g. "GET", "POST"
48    method: String,
49}
50
51impl McpServer {
52    /// Create a new MCP server with the given configuration.
53    pub fn new(config: McpConfig) -> Self {
54        Self {
55            config: Arc::new(config),
56            openapi: None,
57            http_base: None,
58            tool_map: HashMap::new(),
59        }
60    }
61
62    /// Create an MCP server pre-attached to a `RustApi` instance.
63    ///
64    /// This is the most ergonomic way when you already have a built `RustApi`.
65    pub fn from_rustapi(app: &RustApi, config: McpConfig) -> Self {
66        let mut server = Self::new(config);
67        let spec = app.openapi_spec().clone();
68        server.openapi = Some(spec.clone());
69        server.rebuild_tool_map(&spec);
70        server
71    }
72
73    /// Create from an explicit OpenAPI spec.
74    pub fn from_spec(config: McpConfig, spec: &OpenApiSpec) -> Self {
75        let mut server = Self::new(config);
76        server.openapi = Some(spec.clone());
77        server.rebuild_tool_map(spec);
78        server
79    }
80
81    /// Attach (or replace) the OpenAPI spec used for discovery.
82    ///
83    /// Call this after `RustApi::auto()` / builder if you built the app first.
84    pub fn with_openapi(mut self, spec: OpenApiSpec) -> Self {
85        self.rebuild_tool_map(&spec);
86        self.openapi = Some(spec);
87        self
88    }
89
90    /// Configure the base URL of the main RustAPI HTTP server.
91    ///
92    /// When set, `tools/call` will proxy the call over HTTP to this base
93    /// (typically "http://127.0.0.1:8080" when using the concurrent runner).
94    /// This guarantees that tool invocations go through the exact same
95    /// middleware, auth, validation, and handler code as normal traffic.
96    pub fn with_http_base(mut self, base: impl Into<String>) -> Self {
97        self.http_base = Some(base.into());
98        self
99    }
100
101    /// Rebuild the internal tool_name -> execution info map from the OpenAPI spec.
102    /// Called automatically when attaching a spec.
103    fn rebuild_tool_map(&mut self, spec: &OpenApiSpec) {
104        self.tool_map.clear();
105
106        let config = &*self.config;
107
108        let insert_tool = |map: &mut HashMap<String, ToolExecutionInfo>,
109                           path: &str,
110                           method: &str,
111                           op: &rustapi_openapi::Operation| {
112            if !config.allowed_tags.is_empty() {
113                let has_match = op.tags.iter().any(|t| config.allowed_tags.contains(t));
114                if !has_match {
115                    return;
116                }
117            }
118
119            let name = generate_tool_name(method, path, op);
120            let info = ToolExecutionInfo {
121                path_template: path.to_string(),
122                method: method.to_string(),
123            };
124            map.insert(name, info);
125        };
126
127        for (path, path_item) in &spec.paths {
128            if !path_matches_prefixes(path, &config.allowed_path_prefixes) {
129                continue;
130            }
131
132            if let Some(op) = &path_item.get {
133                insert_tool(&mut self.tool_map, path, "GET", op);
134            }
135            if let Some(op) = &path_item.post {
136                insert_tool(&mut self.tool_map, path, "POST", op);
137            }
138            if let Some(op) = &path_item.put {
139                insert_tool(&mut self.tool_map, path, "PUT", op);
140            }
141            if let Some(op) = &path_item.patch {
142                insert_tool(&mut self.tool_map, path, "PATCH", op);
143            }
144            if let Some(op) = &path_item.delete {
145                insert_tool(&mut self.tool_map, path, "DELETE", op);
146            }
147        }
148    }
149
150    /// Get a reference to the active configuration.
151    pub fn config(&self) -> &McpConfig {
152        &self.config
153    }
154
155    /// Return the capabilities this server currently advertises.
156    pub fn capabilities(&self) -> Vec<McpCapability> {
157        let mut caps = vec![];
158
159        if self.config.tools_enabled {
160            caps.push(McpCapability::Tools);
161        }
162
163        caps
164    }
165
166    /// Perform the `initialize` handshake.
167    ///
168    /// MCP clients call this first. Returns server info + supported capabilities.
169    pub fn initialize(&self) -> InitializeResult {
170        InitializeResult {
171            name: self.config.name.clone(),
172            version: self.config.version.clone(),
173            description: self.config.description.clone(),
174            capabilities: self.capabilities(),
175        }
176    }
177
178    /// Discover and return the list of tools that should be exposed to MCP clients.
179    ///
180    /// Tools are derived from the attached OpenAPI spec (from `RustApi`), filtered
181    /// according to `McpConfig::allowed_tags` and `allowed_path_prefixes`.
182    pub async fn list_tools(&self) -> Result<Vec<McpTool>> {
183        if !self.config.tools_enabled {
184            return Err(crate::error::McpError::CapabilityNotEnabled(
185                "tools".to_string(),
186            ));
187        }
188
189        if let Some(spec) = &self.openapi {
190            let tools = discovery::extract_tools_from_spec(spec, &self.config);
191            Ok(tools)
192        } else {
193            // No spec attached → no tools (safe default)
194            Ok(vec![])
195        }
196    }
197
198    /// Execute a tool call by proxying it as a real HTTP request to the main
199    /// RustAPI server (using the configured `http_base`).
200    ///
201    /// This is the heart of Native MCP: the call goes through your normal
202    /// layers, interceptors, extractors, validation, error handling, etc.
203    pub async fn call_tool(
204        &self,
205        req: crate::types::ToolCallRequest,
206    ) -> Result<crate::types::ToolCallResponse> {
207        let info = self.tool_map.get(&req.name).ok_or_else(|| {
208            McpError::ToolNotFound(format!("no tool registered with name '{}'", req.name))
209        })?;
210
211        let path = substitute_path_params(&info.path_template, &req.arguments);
212
213        let base = self.http_base.as_deref().unwrap_or("http://127.0.0.1:8080");
214
215        let url = format!("{}{}", base.trim_end_matches('/'), path);
216
217        let method =
218            reqwest::Method::from_bytes(info.method.as_bytes()).unwrap_or(reqwest::Method::GET);
219
220        let client = reqwest::Client::new();
221
222        let mut request_builder = client.request(method.clone(), &url);
223
224        // If this looks like a mutating method, send the arguments as JSON body
225        let is_body_method = matches!(info.method.as_str(), "POST" | "PUT" | "PATCH");
226        if is_body_method && !req.arguments.is_empty() {
227            request_builder = request_builder
228                .header("content-type", "application/json")
229                .json(&req.arguments);
230        } else if !is_body_method && !req.arguments.is_empty() {
231            // For GET/DELETE etc, we could turn remaining args into query params.
232            // For MVP we rely on path params; extra args are ignored for now.
233        }
234
235        let resp = request_builder.send().await.map_err(|e| {
236            McpError::ToolExecution(format!("failed to proxy tool call to main API: {}", e))
237        })?;
238
239        let status = resp.status();
240        let is_error = !status.is_success();
241
242        let content = if let Ok(text) = resp.text().await {
243            if text.trim().is_empty() {
244                serde_json::Value::Null
245            } else {
246                serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))
247            }
248        } else {
249            serde_json::Value::Null
250        };
251
252        Ok(crate::types::ToolCallResponse {
253            content,
254            is_error,
255            meta: Some(serde_json::json!({ "proxied_status": status.as_u16() })),
256        })
257    }
258}
259
260/// Result of the `initialize` handshake.
261#[derive(Debug, Clone)]
262pub struct InitializeResult {
263    /// Server name (from config).
264    pub name: String,
265    /// Server version (from config).
266    pub version: String,
267    /// Optional description.
268    pub description: Option<String>,
269    /// Advertised capabilities.
270    pub capabilities: Vec<McpCapability>,
271}
272
273impl McpServer {
274    /// Start serving the MCP protocol over HTTP on the given address.
275    ///
276    /// This starts a sidecar HTTP server (separate from your main RustAPI HTTP server)
277    /// that MCP clients (Claude, etc.) can connect to for tool discovery and invocation.
278    ///
279    /// Supports a minimal JSON-RPC over POST transport for:
280    /// - `initialize`
281    /// - `tools/list`
282    /// - `tools/call` (proxies to the main RustAPI server so full middleware / validation / error handling applies)
283    pub async fn serve(self, addr: &str) -> Result<()> {
284        self.serve_with_shutdown(addr, std::future::pending()).await
285    }
286
287    /// Like `serve`, but with a shutdown signal (e.g. ctrl_c()).
288    pub async fn serve_with_shutdown<F>(self, addr: &str, signal: F) -> Result<()>
289    where
290        F: Future<Output = ()> + Send + 'static,
291    {
292        let addr: SocketAddr = addr
293            .parse()
294            .map_err(|e| McpError::Transport(format!("invalid MCP address '{}': {}", addr, e)))?;
295
296        let listener = TcpListener::bind(addr).await.map_err(|e| {
297            McpError::Transport(format!("failed to bind MCP listener on {}: {}", addr, e))
298        })?;
299
300        info!("🧠 MCP server listening on http://{}", addr);
301
302        let this = Arc::new(self);
303        tokio::pin!(signal);
304
305        loop {
306            tokio::select! {
307                biased;
308
309                accept_result = listener.accept() => {
310                    let (stream, remote) = match accept_result {
311                        Ok(v) => v,
312                        Err(e) => {
313                            error!("MCP accept error: {}", e);
314                            continue;
315                        }
316                    };
317
318                    let io = TokioIo::new(stream);
319                    let mcp = this.clone();
320
321                    tokio::task::spawn(async move {
322                        let service = hyper::service::service_fn(move |req| {
323                            let mcp = mcp.clone();
324                            async move { handle_mcp_http_request(mcp, req).await }
325                        });
326
327                        if let Err(e) = http1::Builder::new()
328                            .serve_connection(io, service)
329                            .await
330                        {
331                            error!("MCP connection error from {}: {}", remote, e);
332                        }
333                    });
334                }
335
336                _ = &mut signal => {
337                    info!("MCP server received shutdown signal");
338                    break;
339                }
340            }
341        }
342
343        Ok(())
344    }
345}
346
347/// Handle a single HTTP request for the MCP protocol (minimal JSON-RPC over POST).
348async fn handle_mcp_http_request(
349    mcp: Arc<McpServer>,
350    req: hyper::Request<Incoming>,
351) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
352    if req.method() != hyper::Method::POST {
353        let body = Bytes::from_static(b"{\"error\":\"MCP transport expects POST requests\"}");
354        return Ok(Response::builder()
355            .status(405)
356            .header("content-type", "application/json")
357            .body(Full::new(body))
358            .expect("static response must build"));
359    }
360
361    let body_bytes = match req.collect().await {
362        Ok(collected) => collected.to_bytes(),
363        Err(e) => {
364            let err_body = format!("{{\"error\":\"failed to read body: {}\"}}", e);
365            return Ok(Response::builder()
366                .status(400)
367                .header("content-type", "application/json")
368                .body(Full::new(Bytes::from(err_body)))
369                .expect("error response must build"));
370        }
371    };
372
373    let json: serde_json::Value = match serde_json::from_slice(&body_bytes) {
374        Ok(v) => v,
375        Err(_) => {
376            return Ok(jsonrpc_error_response(
377                serde_json::Value::Null,
378                -32700,
379                "parse error",
380            ));
381        }
382    };
383
384    let id = json.get("id").cloned().unwrap_or(serde_json::Value::Null);
385    let method = json.get("method").and_then(|m| m.as_str()).unwrap_or("");
386
387    match method {
388        "initialize" => {
389            let init = mcp.initialize();
390            let result = serde_json::json!({
391                "protocolVersion": "2024-11-05",
392                "serverInfo": {
393                    "name": init.name,
394                    "version": init.version
395                },
396                "capabilities": {
397                    "tools": {}
398                }
399            });
400            Ok(jsonrpc_success_response(id, result))
401        }
402        "tools/list" => match mcp.list_tools().await {
403            Ok(tools) => {
404                let tool_defs: Vec<_> = tools
405                    .into_iter()
406                    .map(|t| {
407                        serde_json::json!({
408                            "name": t.name,
409                            "description": t.description,
410                            "inputSchema": t.input_schema
411                        })
412                    })
413                    .collect();
414
415                let result = serde_json::json!({ "tools": tool_defs });
416                Ok(jsonrpc_success_response(id, result))
417            }
418            Err(e) => Ok(jsonrpc_error_response(
419                id,
420                -32603,
421                &format!("internal error: {}", e),
422            )),
423        },
424        "tools/call" => {
425            let params = json.get("params").cloned().unwrap_or(serde_json::json!({}));
426            let name = params
427                .get("name")
428                .and_then(|v| v.as_str())
429                .unwrap_or("")
430                .to_string();
431            let arguments: std::collections::HashMap<String, serde_json::Value> = params
432                .get("arguments")
433                .and_then(|v| serde_json::from_value(v.clone()).ok())
434                .unwrap_or_default();
435
436            let tool_req = crate::types::ToolCallRequest { name, arguments };
437
438            match mcp.call_tool(tool_req).await {
439                Ok(resp) => {
440                    // Shape the response per MCP tools/call convention (protocol 2024-11-05):
441                    // The result contains a "content" array of content blocks + "isError" flag.
442                    // We serialize non-string content as pretty JSON text for broad client compatibility.
443                    let text = if resp.content.is_null() {
444                        String::new()
445                    } else if let Some(s) = resp.content.as_str() {
446                        s.to_owned()
447                    } else {
448                        serde_json::to_string_pretty(&resp.content)
449                            .unwrap_or_else(|_| resp.content.to_string())
450                    };
451
452                    let result = serde_json::json!({
453                        "content": [{
454                            "type": "text",
455                            "text": text
456                        }],
457                        "isError": resp.is_error
458                    });
459                    Ok(jsonrpc_success_response(id, result))
460                }
461                Err(e) => {
462                    // Surface execution errors as a successful JSON-RPC but with isError inside the result
463                    // (this is the MCP convention so the client knows it was a tool-level failure).
464                    let result = serde_json::json!({
465                        "content": [{
466                            "type": "text",
467                            "text": format!("Tool execution error: {}", e)
468                        }],
469                        "isError": true
470                    });
471                    Ok(jsonrpc_success_response(id, result))
472                }
473            }
474        }
475        _ => Ok(jsonrpc_error_response(id, -32601, "method not found")),
476    }
477}
478
479fn jsonrpc_success_response(
480    id: serde_json::Value,
481    result: serde_json::Value,
482) -> Response<Full<Bytes>> {
483    let body = serde_json::json!({
484        "jsonrpc": "2.0",
485        "id": id,
486        "result": result
487    });
488    Response::builder()
489        .header("content-type", "application/json")
490        .body(Full::new(Bytes::from(
491            serde_json::to_vec(&body).expect("json must serialize"),
492        )))
493        .expect("response must build")
494}
495
496fn jsonrpc_error_response(
497    id: serde_json::Value,
498    code: i32,
499    message: &str,
500) -> Response<Full<Bytes>> {
501    let body = serde_json::json!({
502        "jsonrpc": "2.0",
503        "id": id,
504        "error": {
505            "code": code,
506            "message": message
507        }
508    });
509    Response::builder()
510        .header("content-type", "application/json")
511        .body(Full::new(Bytes::from(
512            serde_json::to_vec(&body).expect("json must serialize"),
513        )))
514        .expect("response must build")
515}
516
517/// Helpers duplicated from discovery for tool map building (small & self-contained).
518fn path_matches_prefixes(path: &str, prefixes: &[String]) -> bool {
519    if prefixes.is_empty() {
520        return true;
521    }
522    prefixes.iter().any(|p| path.starts_with(p))
523}
524
525fn generate_tool_name(method: &str, path: &str, op: &rustapi_openapi::Operation) -> String {
526    if let Some(oid) = &op.operation_id {
527        return sanitize_name(oid);
528    }
529    let mut slug = path
530        .trim_start_matches('/')
531        .replace(['/', '{', '}', ':'], "_")
532        .replace(['-', '.', ' '], "_");
533    while slug.contains("__") {
534        slug = slug.replace("__", "_");
535    }
536    let slug = slug.trim_matches('_').to_string();
537    let method_lower = method.to_lowercase();
538    if slug.is_empty() {
539        method_lower
540    } else {
541        format!("{}_{}", method_lower, slug)
542    }
543}
544
545fn sanitize_name(s: &str) -> String {
546    s.chars()
547        .map(|c| {
548            if c.is_alphanumeric() || c == '_' {
549                c
550            } else {
551                '_'
552            }
553        })
554        .collect::<String>()
555        .trim_matches('_')
556        .to_string()
557        .to_lowercase()
558}
559
560/// Substitute {param} placeholders in the path template using values from the MCP arguments.
561fn substitute_path_params(
562    template: &str,
563    args: &std::collections::HashMap<String, serde_json::Value>,
564) -> String {
565    let mut result = template.to_string();
566    for (key, value) in args {
567        let placeholder = format!("{{{}}}", key);
568        if result.contains(&placeholder) {
569            let val_str = match value {
570                serde_json::Value::String(s) => s.clone(),
571                other => other.to_string().trim_matches('"').to_string(),
572            };
573            result = result.replace(&placeholder, &val_str);
574        }
575    }
576    result
577}