Skip to main content

rustapi_mcp/
server.rs

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