diff --git a/CHANGELOG.md b/CHANGELOG.md index 797db56f..186f4f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.504] - 2026-06-19 + +### Added + +- **MCP In-Process Invocation**: `InvocationMode::InProcess` / `Auto` on `McpConfig`. When a `McpServer` is created via `from_rustapi`, tool calls can execute directly through the `Router` + `LayerStack` + interceptors with no network hop. `RustApi::request_dispatcher()` and internal `RequestInvoker` / `RequestDispatcher`. + - Typical result: ~28 µs per call (in-process) vs ~1.3 ms (proxy via live localhost HTTP) for 1000 sequential calls — ~45-50× speedup. +- **`cargo rustapi mcp generate`**: Turn any OpenAPI 3.x spec (FastAPI, Express, Go, etc.) into a running MCP server. Supports `--spec`, `--url`, `--api`, `--target`, tag/path filtering, and `--stdio`. +- **MCP stdio transport**: `--stdio` flag for desktop AI clients (Claude Desktop, etc.). +- New cookbook recipes: In-Process Invocation, OpenAPI→MCP CLI, and stdio transport. Updated main MCP recipe and README. + +### Changed + +- OpenAPI deserialization in `rustapi-openapi` is now significantly more tolerant of real-world/partial specs (added `#[serde(default)]` on maps and optional fields) so the CLI works reliably with external APIs. +- `Router` now derives `Clone` (aids in-process sharing patterns). +- `McpConfig` gained `invocation_mode(...)`. + +### Documentation + +- Major updates to MCP coverage in README, cookbook, and plan documents (`in_process_mcp_invocation.md`, `openapi_to_mcp_cli.md`). + ## [0.1.410] - 2026-03-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index edfde0ff..c3f8c4ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,7 +400,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cargo-rustapi" -version = "0.1.501" +version = "0.1.504" dependencies = [ "anyhow", "assert_cmd", @@ -412,6 +412,8 @@ dependencies = [ "notify-debouncer-mini", "predicates", "reqwest", + "rustapi-mcp", + "rustapi-openapi", "serde", "serde_json", "serde_yaml", @@ -3605,7 +3607,7 @@ dependencies = [ [[package]] name = "rustapi-core" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-stream", "async-trait", @@ -3650,7 +3652,7 @@ dependencies = [ [[package]] name = "rustapi-extras" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-trait", "aws-lc-rs", @@ -3694,7 +3696,7 @@ dependencies = [ [[package]] name = "rustapi-grpc" -version = "0.1.501" +version = "0.1.504" dependencies = [ "prost", "rustapi-core", @@ -3705,7 +3707,7 @@ dependencies = [ [[package]] name = "rustapi-macros" -version = "0.1.501" +version = "0.1.504" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3718,7 +3720,7 @@ dependencies = [ [[package]] name = "rustapi-mcp" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-trait", "bytes", @@ -3740,7 +3742,7 @@ dependencies = [ [[package]] name = "rustapi-openapi" -version = "0.1.501" +version = "0.1.504" dependencies = [ "bytes", "http", @@ -3752,7 +3754,7 @@ dependencies = [ [[package]] name = "rustapi-rs" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-trait", "base64", @@ -3780,7 +3782,7 @@ dependencies = [ [[package]] name = "rustapi-testing" -version = "0.1.501" +version = "0.1.504" dependencies = [ "bytes", "http", @@ -3797,7 +3799,7 @@ dependencies = [ [[package]] name = "rustapi-toon" -version = "0.1.501" +version = "0.1.504" dependencies = [ "bytes", "futures-util", @@ -3814,7 +3816,7 @@ dependencies = [ [[package]] name = "rustapi-validate" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-trait", "http", @@ -3830,7 +3832,7 @@ dependencies = [ [[package]] name = "rustapi-view" -version = "0.1.501" +version = "0.1.504" dependencies = [ "bytes", "http", @@ -3846,7 +3848,7 @@ dependencies = [ [[package]] name = "rustapi-ws" -version = "0.1.501" +version = "0.1.504" dependencies = [ "async-trait", "bytes", diff --git a/Cargo.toml b/Cargo.toml index d9c05250..1314c327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "0.1.501" +version = "0.1.504" edition = "2021" authors = ["RustAPI Contributors, Tuntii"] license = "MIT OR Apache-2.0" @@ -150,3 +150,4 @@ strip = false + diff --git a/README.md b/README.md index 55f639aa..70f16eea 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ Meta features: `core` (default), `protocol-all`, `extras-all`, `full`. - **Crate consolidation (13 → 9):** `rustapi-testing`, `rustapi-jobs`, `rustapi-view`, and `rustapi-toon` merged into `rustapi-core` and `rustapi-extras` as feature-gated modules. - **Embedded Isometric System Dashboard:** Live `/dashboard` with bento-grid layout, execution-flow visualization, and time-travel replay browser. -- **Native MCP progress:** Cookbook recipe, `mcp_tools` runnable example, and end-to-end tests for tool discovery + real proxied invocation. +- **Native MCP (full featured):** In-process invocation (~28µs per call), `cargo rustapi mcp generate` for any OpenAPI spec, stdio transport, and improved cookbook coverage. - Dual-stack runtime: simultaneous HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) - WebSocket permessage-deflate compression - `rustapi-grpc` crate: optional Tonic/Prost-based gRPC alongside HTTP (`run_rustapi_and_grpc`) @@ -266,11 +266,12 @@ Meta features: `core` (default), `protocol-all`, `extras-all`, `full`. - Live architectural view of request routing across the **Ultra Fast**, **Fast**, and **Full** execution paths. - Interactive endpoint visualization for topology inspection, route grouping, and runtime status awareness. - Time-travel replay UI for browsing recorded HTTP traffic, selecting a historical request, and inspecting replay state directly from the dashboard. -- [~] Native MCP (Model Context Protocol) Orchestration - - Embedded MCP server that exposes RustAPI endpoints as discoverable tools for LLMs and external AI agents. - - Automatic capability discovery and direct tool-style invocation for compatible clients such as Claude and other multi-agent runtimes. - - Framework-level orchestration layer for agent-to-endpoint communication (core + examples + cookbook + e2e tests done: discovery + proxied invocation through full pipeline + sidecar runner + runnable `mcp_tools` example). - - Remaining polish: admin token enforcement in transport, optional zero-copy in-process invoker (current design uses correct localhost proxy), more client conformance tests. +- [x] Native MCP (Model Context Protocol) Orchestration + - Embedded MCP server + `rustapi mcp generate` CLI turns any OpenAPI 3.x spec into agent tools. + - In-process invocation path: ~28 µs per call (vs ~1.3 ms for localhost proxy with live server) for 1000 sequential calls. + - stdio transport for desktop clients (Claude Desktop etc.). + - Tag/path-prefix filtering, full pipeline respect (or zero-copy in-process when co-located). + - Cookbook recipes for native use, CLI generator, in-process mode, and stdio. ## Documentation diff --git a/crates/cargo-rustapi/Cargo.toml b/crates/cargo-rustapi/Cargo.toml index a0e8d5f5..1ee681df 100644 --- a/crates/cargo-rustapi/Cargo.toml +++ b/crates/cargo-rustapi/Cargo.toml @@ -29,7 +29,7 @@ notify = { version = "8.0", optional = true } notify-debouncer-mini = { version = "0.7", optional = true } # Async -tokio = { workspace = true, features = ["process", "fs", "macros", "rt-multi-thread", "time", "signal", "sync"] } +tokio = { workspace = true, features = ["process", "fs", "macros", "rt-multi-thread", "time", "signal", "sync", "io-std"] } # Serialization serde = { workspace = true } @@ -40,6 +40,10 @@ toml = "1.1" # HTTP client for fetching remote specs reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false, optional = true } +# MCP support (OpenAPI → MCP server generator) +rustapi-mcp = { workspace = true, optional = true } +rustapi-openapi = { workspace = true, optional = true } + # Utilities tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -51,7 +55,8 @@ assert_cmd = "2.2" predicates = "3.1" [features] -default = ["remote-spec", "replay"] +default = ["remote-spec", "replay", "mcp"] native-watch = ["dep:notify", "dep:notify-debouncer-mini"] remote-spec = ["dep:reqwest"] replay = ["dep:reqwest"] +mcp = ["dep:rustapi-mcp", "dep:rustapi-openapi", "remote-spec"] diff --git a/crates/cargo-rustapi/README.md b/crates/cargo-rustapi/README.md index 395533a1..fdcb2de2 100644 --- a/crates/cargo-rustapi/README.md +++ b/crates/cargo-rustapi/README.md @@ -24,6 +24,7 @@ cargo install cargo-rustapi | `cargo rustapi new --preset ` | Start from opinionated `prod-api`, `ai-api`, or `realtime-api` feature bundles | | `cargo rustapi generate resource ` | Scaffold a new API resource (Model + Handlers + Tests) | | `cargo rustapi client --spec --language ` | Generate a client library (Rust, TS, Python) from OpenAPI spec | +| `cargo rustapi mcp generate --spec --target ` | Turn ANY OpenAPI spec into a live MCP server for AI agents (FastAPI, Go, etc.) | | `cargo rustapi deploy ` | Generate deployment configs for Docker, Fly.io, Railway, or Shuttle | | `cargo rustapi migrate ` | Database migration commands (create, run, revert, status, reset) | | `cargo rustapi replay ` | Work with time-travel replay entries from a running RustAPI service | diff --git a/crates/cargo-rustapi/src/cli.rs b/crates/cargo-rustapi/src/cli.rs index ab113e79..58a5fc5d 100644 --- a/crates/cargo-rustapi/src/cli.rs +++ b/crates/cargo-rustapi/src/cli.rs @@ -6,6 +6,9 @@ use crate::commands::{ self, AddArgs, BenchArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs, NewArgs, ObservabilityArgs, RunArgs, WatchArgs, }; + +#[cfg(feature = "mcp")] +use crate::commands::McpGenerateArgs; use clap::{Parser, Subcommand}; /// The official CLI tool for the RustAPI framework. Scaffold new projects, run development servers, and manage database migrations. @@ -62,6 +65,11 @@ enum Commands { /// Generate API client from OpenAPI spec Client(ClientArgs), + /// MCP tools — turn any OpenAPI spec into an MCP server for agents + #[cfg(feature = "mcp")] + #[command(subcommand)] + Mcp(McpCommands), + /// Deploy to various platforms #[command(subcommand)] Deploy(DeployArgs), @@ -87,9 +95,22 @@ impl Cli { Commands::Migrate(args) => commands::migrate(args).await, Commands::Docs { port } => commands::open_docs(port).await, Commands::Client(args) => commands::client(args).await, + #[cfg(feature = "mcp")] + Commands::Mcp(McpCommands::Generate(args)) => commands::mcp_generate(args).await, Commands::Deploy(args) => commands::deploy(args).await, #[cfg(feature = "replay")] Commands::Replay(args) => commands::replay(args).await, } } } + +#[cfg(feature = "mcp")] +#[derive(clap::Subcommand, Debug)] +enum McpCommands { + /// Generate a running MCP server from an OpenAPI specification. + /// + /// Works with FastAPI, Express, Go, Spring, or any other API that + /// publishes an OpenAPI 3.x document. All tool calls are forwarded + /// (proxied) to the real backend. + Generate(McpGenerateArgs), +} diff --git a/crates/cargo-rustapi/src/commands/mcp.rs b/crates/cargo-rustapi/src/commands/mcp.rs new file mode 100644 index 00000000..e9e482c3 --- /dev/null +++ b/crates/cargo-rustapi/src/commands/mcp.rs @@ -0,0 +1,380 @@ +//! MCP command group +//! +//! `rustapi mcp generate` - Turn any OpenAPI 3.x spec into a running MCP server. +//! Tool calls are proxied to the real backend API. + +use anyhow::{Context, Result}; +use clap::Args; + +#[cfg(feature = "mcp")] +use rustapi_mcp::{McpConfig, McpServer}; +#[cfg(feature = "mcp")] +use rustapi_openapi::OpenApiSpec; + +/// Arguments for `rustapi mcp generate` +#[derive(Args, Debug)] +pub struct McpGenerateArgs { + /// Path to an OpenAPI spec file (JSON or YAML) + #[arg(long, value_name = "FILE", conflicts_with_all = ["url", "api"])] + pub spec: Option, + + /// URL to fetch the OpenAPI spec from + #[arg(long, value_name = "URL", conflicts_with_all = ["spec", "api"])] + pub url: Option, + + /// Base URL of a running service. Will try to fetch /openapi.json + /// and use the base as the proxy target. + #[arg(long, value_name = "URL", conflicts_with_all = ["spec", "url"])] + pub api: Option, + + /// Target backend that MCP `tools/call` should proxy to + /// (e.g. http://localhost:8000). Required unless --api is supplied. + #[arg(long, value_name = "URL")] + pub target: Option, + + /// Port the MCP server will listen on (for agents / Claude Desktop etc.) + #[arg(long, default_value_t = 9090)] + pub port: u16, + + /// Human name for the MCP server (shown to LLM clients) + #[arg(long)] + pub name: Option, + + /// Comma-separated list of tags. Only operations carrying at least one + /// of these tags will be exposed as MCP tools. + #[arg(long, value_name = "TAGS")] + pub tags: Option, + + /// Only expose paths that start with this prefix (e.g. "/api/v1") + #[arg(long, value_name = "PREFIX")] + pub allow_path_prefix: Option, + + /// Use stdio transport instead of HTTP. + /// + /// This is useful for local AI clients (e.g. Claude Desktop) that speak + /// MCP over standard input/output. + #[arg(long)] + pub stdio: bool, +} + +/// Execute `rustapi mcp generate` +pub async fn mcp_generate(args: McpGenerateArgs) -> Result<()> { + #[cfg(not(feature = "mcp"))] + { + anyhow::bail!( + "MCP support is not enabled in this build of cargo-rustapi.\n\ + Rebuild with the 'mcp' feature or use a build that includes it." + ); + } + + #[cfg(feature = "mcp")] + { + println!("🧠 RustAPI MCP generator"); + println!(" Loading OpenAPI spec..."); + + let spec_input = resolve_spec_source(&args)?; + let openapi: OpenApiSpec = load_openapi_spec(&spec_input) + .await + .with_context(|| format!("Failed to load OpenAPI spec from {}", spec_input))?; + + let target = resolve_target(&args)?; + + let mut config = McpConfig::new(); + + if let Some(name) = &args.name { + config = config.name(name.clone()); + } else { + // Derive a reasonable default name from the OpenAPI title + let title = &openapi.info.title; + config = config.name(format!("{}-mcp", sanitize_name(title))); + } + + if let Some(tags_str) = &args.tags { + let tags: Vec = tags_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !tags.is_empty() { + config = config.allowed_tags(tags); + } + } + + if let Some(prefix) = &args.allow_path_prefix { + config = config.allow_path_prefix(prefix.clone()); + } + + let mut mcp = McpServer::from_spec(config, &openapi); + mcp = mcp.with_http_base(target.clone()); + + let addr = format!("127.0.0.1:{}", args.port); // safer default for local tool + + println!(" ✓ Spec loaded"); + println!(" → Proxying tool calls to: {}", target); + + if args.stdio { + println!("🧠 MCP stdio transport active. Waiting for JSON-RPC on stdin..."); + run_stdio(mcp).await?; + return Ok(()); + } + + println!(" → MCP server listening on: http://{}", addr); + println!(); + println!("Useful test commands:"); + println!( + " curl -X POST http://127.0.0.1:{} -H 'content-type: application/json' \\", + args.port + ); + println!(" -d '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}}'"); + println!(); + println!( + " curl -X POST http://127.0.0.1:{} -H 'content-type: application/json' \\", + args.port + ); + println!(" -d '{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}}'"); + println!(); + println!("Press Ctrl+C to stop."); + + let shutdown = async { + let _ = tokio::signal::ctrl_c().await; + }; + + mcp.serve_with_shutdown(&addr, shutdown) + .await + .map_err(|e| anyhow::anyhow!("MCP server error: {}", e))?; + + Ok(()) + } +} + +#[cfg(feature = "mcp")] +async fn run_stdio(mcp: rustapi_mcp::McpServer) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + let mut writer = ::tokio::io::stdout(); + + let mut line = String::new(); + + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + break; + } + + let json: serde_json::Value = match serde_json::from_str(line.trim()) { + Ok(v) => v, + Err(e) => { + let err = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { "code": -32700, "message": format!("parse error: {}", e) } + }); + let mut out = serde_json::to_vec(&err).unwrap(); + out.push(b'\n'); + let _ = writer.write_all(&out).await; + let _ = writer.flush().await; + continue; + } + }; + + let id = json.get("id").cloned().unwrap_or(serde_json::Value::Null); + let method = json.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + let result_val = match method { + "initialize" => { + let init = mcp.initialize(); + serde_json::json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { "name": init.name, "version": init.version }, + "capabilities": { "tools": {} } + }) + } + "tools/list" => match mcp.list_tools().await { + Ok(tools) => { + let tool_defs: Vec<_> = tools + .into_iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema + }) + }) + .collect(); + serde_json::json!({ "tools": tool_defs }) + } + Err(e) => serde_json::json!({ "code": -32603, "message": e.to_string() }), + }, + "tools/call" => { + let params = json.get("params").cloned().unwrap_or(serde_json::json!({})); + let name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let arguments: std::collections::HashMap = params + .get("arguments") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let tool_req = rustapi_mcp::ToolCallRequest { name, arguments }; + + match mcp.call_tool(tool_req).await { + Ok(resp) => { + let text = if resp.content.is_null() { + String::new() + } else if let Some(s) = resp.content.as_str() { + s.to_owned() + } else { + serde_json::to_string_pretty(&resp.content) + .unwrap_or_else(|_| resp.content.to_string()) + }; + serde_json::json!({ + "content": [{ "type": "text", "text": text }], + "isError": resp.is_error + }) + } + Err(e) => { + serde_json::json!({ + "content": [{ "type": "text", "text": format!("Tool error: {}", e) }], + "isError": true + }) + } + } + } + _ => { + serde_json::json!({ "error": { "code": -32601, "message": "method not found" } }) + } + }; + + let resp = if result_val.get("error").is_some() { + serde_json::json!({ "jsonrpc": "2.0", "id": id, "error": result_val["error"] }) + } else { + serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result_val }) + }; + + let mut buf = serde_json::to_vec(&resp).unwrap(); + buf.push(b'\n'); + writer.write_all(&buf).await?; + writer.flush().await?; + } + + Ok(()) +} + +fn resolve_spec_source(args: &McpGenerateArgs) -> Result { + if let Some(s) = &args.spec { + return Ok(s.clone()); + } + if let Some(u) = &args.url { + return Ok(u.clone()); + } + if let Some(a) = &args.api { + let base = a.trim_end_matches('/'); + return Ok(format!("{}/openapi.json", base)); + } + anyhow::bail!("One of --spec , --url , or --api is required") +} + +fn resolve_target(args: &McpGenerateArgs) -> Result { + if let Some(t) = &args.target { + return Ok(t.clone()); + } + if let Some(a) = &args.api { + return Ok(a.clone()); + } + anyhow::bail!("--target is required (or use --api which doubles as target)") +} + +async fn load_openapi_spec(source: &str) -> Result { + let content = if source.starts_with("http://") || source.starts_with("https://") { + // remote-spec is pulled in by the mcp feature + reqwest::get(source) + .await + .context("Failed to fetch spec over HTTP")? + .text() + .await + .context("Failed to read response body")? + } else { + tokio::fs::read_to_string(source) + .await + .with_context(|| format!("Failed to read spec file: {}", source))? + }; + + let lower = source.to_ascii_lowercase(); + let spec = if lower.ends_with(".yaml") || lower.ends_with(".yml") { + serde_yaml::from_str(&content).context("Failed to deserialize YAML OpenAPI spec")? + } else if lower.ends_with(".json") { + serde_json::from_str(&content).context("Failed to deserialize JSON OpenAPI spec")? + } else { + // Unknown extension — try JSON then YAML + serde_json::from_str(&content) + .or_else(|_| serde_yaml::from_str(&content)) + .context("Failed to parse spec as JSON or YAML OpenAPI document")? + }; + + Ok(spec) +} + +fn sanitize_name(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_produces_reasonable_name() { + assert_eq!(sanitize_name("My Cool API!"), "my-cool-api"); + } + + #[tokio::test] + async fn load_and_build_mcp_server_from_minimal_spec() { + // A minimal but complete-enough OpenAPI that roundtrips through our deserializer + let json = r#"{ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1"}, + "paths": { + "/ok": { + "get": { + "operationId": "okOp", + "tags": ["public"], + "responses": { + "200": { + "description": "ok", + "content": {"application/json": {"schema": {"type": "object"}}} + } + } + } + } + } + }"#; + + let spec: OpenApiSpec = serde_json::from_str(json).expect("spec must deserialize"); + let cfg = McpConfig::new().allowed_tags(["public"]); + let mcp = McpServer::from_spec(cfg, &spec); + + let tools = mcp.list_tools().await.expect("list_tools"); + assert!( + !tools.is_empty(), + "should have discovered at least one tool" + ); + assert!(tools + .iter() + .any(|t| t.name.contains("ok") || t.name.contains("Op"))); + } +} diff --git a/crates/cargo-rustapi/src/commands/mod.rs b/crates/cargo-rustapi/src/commands/mod.rs index 76e91cd7..6c090e72 100644 --- a/crates/cargo-rustapi/src/commands/mod.rs +++ b/crates/cargo-rustapi/src/commands/mod.rs @@ -30,3 +30,8 @@ pub use watch::{watch, WatchArgs}; mod replay; #[cfg(feature = "replay")] pub use replay::{replay, ReplayArgs}; + +#[cfg(feature = "mcp")] +mod mcp; +#[cfg(feature = "mcp")] +pub use mcp::{mcp_generate, McpGenerateArgs}; diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index fab98676..450f22c9 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -3,16 +3,70 @@ use crate::error::Result; use crate::events::LifecycleHooks; use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor}; -use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT}; +use crate::middleware::{ + BodyLimitLayer, BoxedNext, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT, +}; use crate::response::IntoResponse; use crate::router::{MethodRouter, Router}; use crate::server::Server; +use crate::{Request, Response}; +use http::Extensions; use std::collections::BTreeMap; #[cfg(feature = "dashboard")] use std::collections::BTreeSet; use std::future::Future; +use std::sync::Arc; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; +/// A dispatcher that can drive requests through the RustAPI pipeline +/// (interceptors + layers + router) without any network or serialization overhead. +/// +/// Obtained via [`RustApi::request_dispatcher`]. +#[derive(Clone)] +pub struct RequestDispatcher { + router: Arc, + layers: LayerStack, + interceptors: InterceptorChain, +} + +impl RequestDispatcher { + /// Returns the shared state Extensions from the underlying router. + /// Useful for in-process request construction to preserve `State` etc. + pub fn state_ref(&self) -> Arc { + self.router.state_ref() + } + + /// Dispatch a request through the full stack (interceptors → middleware layers + /// → route handler → response interceptors). + /// + /// This replicates the logic used by the normal HTTP server. + pub async fn dispatch(&self, request: Request) -> Response { + let req = self.interceptors.intercept_request(request); + + let path = req.path().to_owned(); + let method = req.method().clone(); + + let response = if self.layers.is_empty() { + crate::server::route_request_direct(&self.router, req, &path, &method).await + } else { + let router = self.router.clone(); + let p = path.clone(); + let m = method.clone(); + + let routing_handler: BoxedNext = Arc::new(move |r: Request| { + let router = router.clone(); + let pp = p.clone(); + let mm = m.clone(); + Box::pin(async move { crate::server::route_request(&router, r, &pp, &mm).await }) + }); + + self.layers.execute(req, routing_handler).await + }; + + self.interceptors.intercept_response(response) + } +} + /// Main application builder for RustAPI /// /// # Example @@ -380,7 +434,8 @@ impl RustApi { // Store state in the router's shared Extensions so `State` extractor can retrieve it. let state = _state; let mut app = self; - app.router = app.router.state(state); + let r = std::mem::take(&mut app.router); + app.router = r.state(state); app } @@ -1548,6 +1603,11 @@ impl RustApi { self.router } + /// Get a reference to the inner router (for advanced usage, e.g. in-process MCP dispatch). + pub fn router(&self) -> &Router { + &self.router + } + /// Get the layer stack (for testing) pub fn layers(&self) -> &LayerStack { &self.layers @@ -1558,6 +1618,19 @@ impl RustApi { &self.interceptors } + /// Returns a dispatcher that can execute requests directly through this + /// app's router + layers + interceptors, with zero network overhead. + /// + /// This is intended for in-process protocol integrations (e.g. MCP tool calls + /// when running side-by-side with the main HTTP server). + pub fn request_dispatcher(&self) -> RequestDispatcher { + RequestDispatcher { + router: Arc::new(self.router.clone()), + layers: self.layers().clone(), + interceptors: self.interceptors().clone(), + } + } + /// Enable HTTP/3 support with TLS certificates /// /// HTTP/3 requires TLS certificates. For development, you can use @@ -1591,9 +1664,9 @@ impl RustApi { let server = crate::http3::Http3Server::new( &config, - Arc::new(self.router), - Arc::new(self.layers), - Arc::new(self.interceptors), + Arc::new(self.router.clone()), + Arc::new(self.layers.clone()), + Arc::new(self.interceptors.clone()), ) .await?; @@ -1633,9 +1706,9 @@ impl RustApi { let server = crate::http3::Http3Server::new_with_self_signed( addr, - Arc::new(self.router), - Arc::new(self.layers), - Arc::new(self.interceptors), + Arc::new(self.router.clone()), + Arc::new(self.layers.clone()), + Arc::new(self.interceptors.clone()), ) .await?; diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 90997716..9848d3bc 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -121,7 +121,7 @@ pub mod __private { } // Public API -pub use app::{ProductionDefaultsConfig, RustApi, RustApiConfig}; +pub use app::{ProductionDefaultsConfig, RequestDispatcher, RustApi, RustApiConfig}; #[cfg(feature = "dashboard")] pub use dashboard::{DashboardConfig, DashboardMetrics, DashboardSnapshot}; pub use error::{get_environment, ApiError, Environment, FieldError, Result}; diff --git a/crates/rustapi-core/src/router.rs b/crates/rustapi-core/src/router.rs index c6737040..adb0c808 100644 --- a/crates/rustapi-core/src/router.rs +++ b/crates/rustapi-core/src/router.rs @@ -372,6 +372,7 @@ where } /// Main router +#[derive(Clone)] pub struct Router { inner: MatchitRouter, state: Arc, diff --git a/crates/rustapi-core/src/server.rs b/crates/rustapi-core/src/server.rs index 46124ccf..54c92b77 100644 --- a/crates/rustapi-core/src/server.rs +++ b/crates/rustapi-core/src/server.rs @@ -309,7 +309,7 @@ async fn handle_request( /// Direct routing without middleware chain - maximum performance path #[inline] -async fn route_request_direct( +pub(crate) async fn route_request_direct( router: &Router, mut request: Request, path: &str, @@ -339,7 +339,7 @@ async fn route_request_direct( /// Route request through the router (used by middleware chain) #[inline] -async fn route_request( +pub(crate) async fn route_request( router: &Router, mut request: Request, path: &str, diff --git a/crates/rustapi-mcp/src/config.rs b/crates/rustapi-mcp/src/config.rs index fbce8bc3..12e70cf1 100644 --- a/crates/rustapi-mcp/src/config.rs +++ b/crates/rustapi-mcp/src/config.rs @@ -41,6 +41,24 @@ pub struct McpConfig { /// Maximum number of tools to advertise in one `tools/list` response. /// Helps protect against very large route sets. pub max_tools: usize, + + /// How `tools/call` should be executed. + /// Proxy (default) always goes over HTTP (correct and works for external targets). + /// InProcess / Auto are for when an in-process RustApi instance is available. + pub invocation_mode: InvocationMode, +} + +/// Controls whether tool invocation goes through the normal HTTP path or +/// a direct in-memory call (when available). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InvocationMode { + /// Always proxy via the configured `http_base` (safest, works everywhere). + #[default] + Proxy, + /// Use direct in-process invocation when a RustApi runtime is attached. + InProcess, + /// Choose automatically (InProcess if runtime available, else Proxy). + Auto, } impl Default for McpConfig { @@ -55,6 +73,7 @@ impl Default for McpConfig { admin_token: None, expose_detailed_errors: false, max_tools: 256, + invocation_mode: InvocationMode::Proxy, } } } @@ -124,4 +143,10 @@ impl McpConfig { self.max_tools = max; self } + + /// Choose invocation strategy for tool calls. + pub fn invocation_mode(mut self, mode: InvocationMode) -> Self { + self.invocation_mode = mode; + self + } } diff --git a/crates/rustapi-mcp/src/invocation.rs b/crates/rustapi-mcp/src/invocation.rs new file mode 100644 index 00000000..32307d3b --- /dev/null +++ b/crates/rustapi-mcp/src/invocation.rs @@ -0,0 +1,58 @@ +//! In-process request invocation for MCP (zero TCP overhead). +//! +//! When an MCP server is created via `from_rustapi`, we can drive tool calls +//! directly through the application's Router + LayerStack + Interceptors. + +use crate::config::{InvocationMode, McpConfig}; +use rustapi_core::{Request, RequestDispatcher, Response as CoreResponse}; +use std::sync::Arc; + +/// Executes tool calls directly against a RustAPI instance (in-process). +/// +/// This bypasses the network entirely while still running the full pipeline: +/// request interceptors, middleware layers, route handler + extractors/validators, +/// response interceptors. +#[derive(Clone)] +pub struct RequestInvoker { + dispatcher: Arc, + config: Arc, +} + +impl std::fmt::Debug for RequestInvoker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RequestInvoker") + .field("mode", &self.config.invocation_mode) + .finish_non_exhaustive() + } +} + +impl RequestInvoker { + pub(crate) fn new(dispatcher: RequestDispatcher, config: Arc) -> Self { + Self { + dispatcher: Arc::new(dispatcher), + config, + } + } + + /// Perform an in-process tool call. + /// + /// The caller is responsible for having already looked up the route info + /// and built a proper `core::Request`. + pub async fn invoke(&self, req: Request) -> CoreResponse { + self.dispatcher.dispatch(req).await + } + + /// Whether this invoker should be used based on the current mode. + pub fn should_use(&self) -> bool { + match self.config.invocation_mode { + InvocationMode::Proxy => false, + InvocationMode::InProcess | InvocationMode::Auto => true, + } + } + + /// Expose the router's shared state so callers can construct proper + /// `core::Request`s that have access to `State` and other attached state. + pub fn state_ref(&self) -> Arc { + self.dispatcher.state_ref() + } +} diff --git a/crates/rustapi-mcp/src/lib.rs b/crates/rustapi-mcp/src/lib.rs index ac104894..7597a231 100644 --- a/crates/rustapi-mcp/src/lib.rs +++ b/crates/rustapi-mcp/src/lib.rs @@ -65,7 +65,7 @@ pub mod server; pub mod types; // Re-export the most important items at the crate root for convenience. -pub use config::McpConfig; +pub use config::{InvocationMode, McpConfig}; pub use error::{McpError, Result}; pub use runner::{ run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, BoxError, @@ -78,7 +78,7 @@ pub use rustapi_openapi::OpenApiSpec; /// Prelude for common MCP types. pub mod prelude { - pub use crate::config::McpConfig; + pub use crate::config::{InvocationMode, McpConfig}; pub use crate::error::{McpError, Result}; pub use crate::runner::{ run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, @@ -93,7 +93,4 @@ pub(crate) mod transport { } /// Internal helpers for executing tool calls through the normal RustAPI stack. -pub(crate) mod invocation { - // Will eventually contain code that constructs a Request and drives it - // through Router / LayerStack / interceptors without going over the network. -} +pub(crate) mod invocation; diff --git a/crates/rustapi-mcp/src/server.rs b/crates/rustapi-mcp/src/server.rs index b93d3620..9fa3d1c7 100644 --- a/crates/rustapi-mcp/src/server.rs +++ b/crates/rustapi-mcp/src/server.rs @@ -1,8 +1,9 @@ //! The main `McpServer` type and lifecycle. -use crate::config::McpConfig; +use crate::config::{InvocationMode, McpConfig}; use crate::discovery; use crate::error::{McpError, Result}; +use crate::invocation::RequestInvoker; use crate::types::{McpCapability, McpTool}; use bytes::Bytes; use http_body_util::{BodyExt, Full}; @@ -10,7 +11,7 @@ use hyper::body::Incoming; use hyper::server::conn::http1; use hyper::Response; use hyper_util::rt::TokioIo; -use rustapi_core::RustApi; +use rustapi_core::{Request as CoreRequest, RustApi}; use rustapi_openapi::OpenApiSpec; use std::collections::HashMap; use std::convert::Infallible; @@ -36,6 +37,10 @@ pub struct McpServer { /// Internal mapping from tool name (as advertised to MCP) to the original HTTP route info. /// Used to reconstruct the correct request when a tool is called. tool_map: HashMap, + + /// Optional in-process invoker. Present when created via `from_rustapi` + /// and the configured `invocation_mode` allows direct dispatch. + invoker: Option, } /// Internal info needed to turn an MCP tool/call into an actual HTTP request @@ -56,17 +61,30 @@ impl McpServer { openapi: None, http_base: None, tool_map: HashMap::new(), + invoker: None, } } /// Create an MCP server pre-attached to a `RustApi` instance. /// /// This is the most ergonomic way when you already have a built `RustApi`. + /// If `invocation_mode` is `InProcess` or `Auto`, a direct in-process + /// dispatcher is captured for zero-overhead tool calls (see `RequestInvoker`). pub fn from_rustapi(app: &RustApi, config: McpConfig) -> Self { let mut server = Self::new(config); + let spec = app.openapi_spec().clone(); server.openapi = Some(spec.clone()); server.rebuild_tool_map(&spec); + + // Only capture in-process dispatcher when the configured mode can actually use it. + // This avoids unnecessary work and state cloning when the user explicitly wants Proxy. + let mode = server.config.invocation_mode; + if mode == InvocationMode::InProcess || mode == InvocationMode::Auto { + let dispatcher = app.request_dispatcher(); + server.invoker = Some(RequestInvoker::new(dispatcher, server.config.clone())); + } + server } @@ -210,6 +228,25 @@ impl McpServer { let path = substitute_path_params(&info.path_template, &req.arguments); + // Decide execution strategy + let use_inprocess = if let Some(inv) = &self.invoker { + inv.should_use() + } else { + false + }; + + if use_inprocess { + if let Some(invoker) = &self.invoker { + let state = invoker.state_ref(); + let core_req = self + .build_core_request(info, &req.arguments, &path, state) + .await?; + let core_resp = invoker.invoke(core_req).await; + return self.core_response_to_tool_response(core_resp).await; + } + } + + // Fallback / default: proxy via HTTP (current behavior) let base = self.http_base.as_deref().unwrap_or("http://127.0.0.1:8080"); let url = format!("{}{}", base.trim_end_matches('/'), path); @@ -255,6 +292,92 @@ impl McpServer { meta: Some(serde_json::json!({ "proxied_status": status.as_u16() })), }) } + + /// Build a `core::Request` suitable for in-process dispatch. + async fn build_core_request( + &self, + info: &ToolExecutionInfo, + arguments: &std::collections::HashMap, + substituted_path: &str, + state: Arc, + ) -> Result { + use bytes::Bytes; + use http::{Method, Request as HttpRequest, Uri, Version}; + use rustapi_core::{BodyVariant, PathParams}; + + let method = Method::from_bytes(info.method.as_bytes()).unwrap_or(Method::GET); + let uri: Uri = substituted_path + .parse() + .unwrap_or_else(|_| "/".parse().expect("valid fallback uri")); + + let mut builder = HttpRequest::builder() + .method(method.clone()) + .uri(uri) + .version(Version::HTTP_11); + + let is_body_method = matches!(info.method.as_str(), "POST" | "PUT" | "PATCH"); + + let body_bytes = if is_body_method && !arguments.is_empty() { + builder = builder.header("content-type", "application/json"); + let json = serde_json::to_vec(arguments).unwrap_or_default(); + Bytes::from(json) + } else { + Bytes::new() + }; + + let http_req = builder + .body(()) + .map_err(|e| McpError::ToolExecution(format!("failed to build request: {}", e)))?; + + let (mut parts, _) = http_req.into_parts(); + + parts.method = method; + parts.uri = substituted_path.parse().unwrap_or(parts.uri); + + let path_params = PathParams::new(); + + let core_req = + CoreRequest::new(parts, BodyVariant::Buffered(body_bytes), state, path_params); + + Ok(core_req) + } + + /// Convert a core Response back into a ToolCallResponse. + async fn core_response_to_tool_response( + &self, + resp: rustapi_core::Response, + ) -> Result { + use http_body_util::BodyExt; + + let status = resp.status(); + let is_error = !status.is_success(); + + // Collect body + let body_bytes = resp + .into_body() + .collect() + .await + .map(|collected| collected.to_bytes()) + .unwrap_or_default(); + + let content = if body_bytes.is_empty() { + serde_json::Value::Null + } else if let Ok(text) = std::str::from_utf8(&body_bytes) { + if text.trim().is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str(text).unwrap_or(serde_json::Value::String(text.to_string())) + } + } else { + serde_json::Value::String(String::from_utf8_lossy(&body_bytes).to_string()) + }; + + Ok(crate::types::ToolCallResponse { + content, + is_error, + meta: Some(serde_json::json!({ "status": status.as_u16(), "in_process": true })), + }) + } } /// Result of the `initialize` handshake. diff --git a/crates/rustapi-mcp/tests/mcp_e2e.rs b/crates/rustapi-mcp/tests/mcp_e2e.rs index b8cc6878..41ba98d6 100644 --- a/crates/rustapi-mcp/tests/mcp_e2e.rs +++ b/crates/rustapi-mcp/tests/mcp_e2e.rs @@ -8,7 +8,9 @@ use std::time::Duration; use rustapi_rs::prelude::*; -use rustapi_rs::protocol::mcp::{run_rustapi_and_mcp_with_shutdown, McpConfig, McpServer}; +use rustapi_rs::protocol::mcp::{ + run_rustapi_and_mcp_with_shutdown, InvocationMode, McpConfig, McpServer, +}; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -299,6 +301,118 @@ async fn test_mcp_tool_call_get_with_path_param_and_post_body() { let _ = tokio::time::timeout(Duration::from_secs(3), server_handle).await; } +/// Real benchmark: proxy (with live HTTP server) vs in-process for 1000 sequential tool calls. +/// +/// This test is ignored by default because wall-clock timing assertions can be flaky +/// under CI load. Run explicitly with `cargo test -- --ignored`. +#[tokio::test] +#[ignore] +async fn bench_proxy_vs_inprocess() { + use tokio::sync::oneshot; + let n = 1000usize; + + // --- Discover tool name (same for both) --- + let app_disc = RustApi::auto(); + let mcp_disc = McpServer::from_rustapi(&app_disc, McpConfig::new().allowed_tags(["agent"])); + let tools = mcp_disc.list_tools().await.unwrap(); + let tool_name = tools + .iter() + .find(|t| t.name.contains("weather") || t.name.contains("get_weather")) + .map(|t| t.name.clone()) + .expect("expected a weather tool in the test module"); + + let call_req = rustapi_rs::protocol::mcp::ToolCallRequest { + name: tool_name, + arguments: [("city".to_string(), serde_json::json!("Berlin"))].into(), + }; + + // === In-process (dispatcher, zero network) === + let app_in = RustApi::auto(); + let mcp_in = McpServer::from_rustapi( + &app_in, + McpConfig::new() + .allowed_tags(["agent"]) + .invocation_mode(InvocationMode::InProcess), + ); + + // warm up + let _ = mcp_in.call_tool(call_req.clone()).await; + + let start = std::time::Instant::now(); + for _ in 0..n { + let _ = mcp_in.call_tool(call_req.clone()).await.unwrap(); + } + let inproc_dur = start.elapsed(); + + // === Proxy with live HTTP server === + let app_p = RustApi::auto(); + let http_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let http_addr = http_listener.local_addr().unwrap(); + drop(http_listener); + let http_addr_str = format!("127.0.0.1:{}", http_addr.port()); + + let mcp_p = McpServer::from_rustapi( + &app_p, + McpConfig::new() + .allowed_tags(["agent"]) + .invocation_mode(InvocationMode::Proxy), + ); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let http_addr_for_spawn = http_addr_str.clone(); + let server_handle = tokio::spawn(async move { + app_p + .run_with_shutdown(&http_addr_for_spawn, async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + // Give server time to start + tokio::time::sleep(Duration::from_millis(400)).await; + + // Set base manually (or runner would do it) + let mcp_p = mcp_p.with_http_base(format!("http://{}", http_addr_str)); + + // warm up proxy path + let _ = mcp_p.call_tool(call_req.clone()).await; + + let start = std::time::Instant::now(); + for _ in 0..n { + let _ = mcp_p.call_tool(call_req.clone()).await.unwrap(); + } + let proxy_dur = start.elapsed(); + + // shutdown + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(Duration::from_secs(3), server_handle).await; + + println!( + "\n=== Live Server Benchmark ({} sequential tool calls) ===", + n + ); + println!( + "In-process (direct): {:>8.3?} avg: {:>6.1?} per call", + inproc_dur, + inproc_dur / n as u32 + ); + println!( + "Proxy (live HTTP) : {:>8.3?} avg: {:>6.1?} per call", + proxy_dur, + proxy_dur / n as u32 + ); + + let speedup = proxy_dur.as_secs_f64() / inproc_dur.as_secs_f64(); + println!("Speedup: {:.1}x (in-process is faster)", speedup); + + // Sanity + assert!( + inproc_dur < proxy_dur, + "in-process should be faster than proxy with live server" + ); +} + #[tokio::test] async fn test_mcp_tool_not_found_for_untagged_route() { let app = RustApi::auto(); diff --git a/crates/rustapi-openapi/src/spec.rs b/crates/rustapi-openapi/src/spec.rs index d2dd17b2..9abd78cd 100644 --- a/crates/rustapi-openapi/src/spec.rs +++ b/crates/rustapi-openapi/src/spec.rs @@ -7,48 +7,54 @@ use crate::schema::JsonSchema2020; pub use crate::schema::SchemaRef; /// OpenAPI 3.1.0 specification -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct OpenApiSpec { /// OpenAPI version (always "3.1.0") + #[serde(default = "default_openapi_version")] pub openapi: String, /// API information + #[serde(default)] pub info: ApiInfo, /// JSON Schema dialect (optional, defaults to JSON Schema 2020-12) - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub json_schema_dialect: Option, /// Server list - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub servers: Vec, /// API paths - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub paths: BTreeMap, /// Webhooks - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub webhooks: BTreeMap, /// Components - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub components: Option, /// Security requirements - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub security: Vec>>, /// Tags - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, /// External documentation - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub external_docs: Option, } +fn default_openapi_version() -> String { + "3.1.0".to_string() +} + impl OpenApiSpec { pub fn new(title: impl Into, version: impl Into) -> Self { Self { @@ -421,36 +427,36 @@ pub struct ServerVariable { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PathItem { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub get: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub put: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub post: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub delete: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub options: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub head: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub patch: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub servers: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub parameters: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Operation { - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, @@ -460,12 +466,13 @@ pub struct Operation { pub external_docs: Option, #[serde(skip_serializing_if = "Option::is_none")] pub operation_id: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub parameters: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub request_body: Option, + #[serde(default)] pub responses: BTreeMap, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub security: Vec>>, #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, @@ -515,18 +522,19 @@ pub struct RequestBody { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ResponseSpec { + #[serde(default)] pub description: String, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub content: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MediaType { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub example: Option, } @@ -541,23 +549,23 @@ pub struct Header { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Components { - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub schemas: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub responses: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub parameters: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub examples: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub request_bodies: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub security_schemes: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub links: BTreeMap, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub callbacks: BTreeMap>, } diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index 79580b2b..c7c06e82 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -42,11 +42,11 @@ pub mod core { Handler, HandlerService, HeaderValue, Headers, HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, HealthStatus, Html, IntoResponse, Json, KeepAlive, MethodRouter, Multipart, MultipartConfig, MultipartField, NoContent, Paginate, Paginated, - Path, ProductionDefaultsConfig, Query, Redirect, Request, RequestId, RequestIdLayer, - Response, ResponseBody, Result, Route, RouteHandler, RouteMatch, Router, RustApi, - RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, - StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, UploadedFile, - ValidatedJson, WithStatus, + Path, ProductionDefaultsConfig, Query, Redirect, Request, RequestDispatcher, RequestId, + RequestIdLayer, Response, ResponseBody, Result, Route, RouteHandler, RouteMatch, Router, + RustApi, RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, + StreamBody, StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, + UploadedFile, ValidatedJson, WithStatus, }; pub use rustapi_core::get_environment; @@ -367,11 +367,11 @@ pub mod prelude { CursorPaginate, CursorPaginated, Extension, HeaderValue, Headers, HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, HealthStatus, Html, IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, MultipartField, NoContent, - Paginate, Paginated, Path, ProductionDefaultsConfig, Query, Redirect, Request, RequestId, - RequestIdLayer, Response, Result, Route, Router, RustApi, RustApiConfig, Sse, SseEvent, - State, StaticFile, StaticFileConfig, StatusCode, StreamBody, StreamingMultipart, - StreamingMultipartField, TracingLayer, Typed, TypedPath, UploadedFile, ValidatedJson, - WithStatus, + Paginate, Paginated, Path, ProductionDefaultsConfig, Query, Redirect, Request, + RequestDispatcher, RequestId, RequestIdLayer, Response, Result, Route, Router, RustApi, + RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, + StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, UploadedFile, + ValidatedJson, WithStatus, }; #[cfg(any(feature = "core-compression", feature = "compression"))] diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 0fc59f1b..cd769cbc 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -54,6 +54,9 @@ - [Server-Side Rendering (SSR)](recipes/server_side_rendering.md) - [AI Integration (TOON)](recipes/ai_integration.md) - [MCP Integration (Agent Tools)](recipes/mcp_integration.md) + - [MCP In-Process Invocation](recipes/mcp_in_process.md) + - [OpenAPI to MCP CLI](recipes/mcp_openapi_cli.md) + - [MCP stdio Transport](recipes/mcp_stdio.md) - [Production Tuning](recipes/high_performance.md) - [Response Compression](recipes/compression.md) - [Resilience Patterns](recipes/resilience.md) diff --git a/docs/cookbook/src/recipes/README.md b/docs/cookbook/src/recipes/README.md index 6d1af3d0..aec87800 100644 --- a/docs/cookbook/src/recipes/README.md +++ b/docs/cookbook/src/recipes/README.md @@ -32,6 +32,9 @@ Each recipe follows a simple structure: - [Server-Side Rendering (SSR)](server_side_rendering.md) - [AI Integration (TOON)](ai_integration.md) - [MCP Integration (Agent Tools)](mcp_integration.md) +- [MCP In-Process Invocation](mcp_in_process.md) +- [OpenAPI to MCP CLI](mcp_openapi_cli.md) +- [MCP stdio Transport](mcp_stdio.md) - [Production Tuning](high_performance.md) - [Response Compression](compression.md) - [Resilience Patterns](resilience.md) diff --git a/docs/cookbook/src/recipes/mcp_in_process.md b/docs/cookbook/src/recipes/mcp_in_process.md new file mode 100644 index 00000000..da24a289 --- /dev/null +++ b/docs/cookbook/src/recipes/mcp_in_process.md @@ -0,0 +1,63 @@ +# MCP In-Process Invocation + +By default, `tools/call` from an MCP server proxies over HTTP to your main RustAPI server (even on localhost). This guarantees that every middleware, interceptor, extractor, and validator runs exactly as for normal traffic. + +For high-frequency agent use (many tool calls per prompt), the network/serde overhead of the proxy can add up. RustAPI supports an optional **in-process** invocation path that constructs a `Request` and drives it directly through the `Router` + `LayerStack` with zero TCP or serialization. + +## When to Use In-Process + +- You are using `run_rustapi_and_mcp` (or equivalent) so the MCP sidecar and main HTTP server are in the **same process**. +- You make many sequential or batched tool calls from agents. +- You want the absolute lowest latency while keeping the full pipeline. + +**Do not** use in-process if your MCP server talks to a *remote* RustAPI instance (use the proxy). + +## How to Enable + +```rust +use rustapi_rs::protocol::mcp::{McpConfig, McpServer, InvocationMode}; + +let mcp = McpServer::from_rustapi( + &app, + McpConfig::new() + .allowed_tags(["agent"]) + .invocation_mode(InvocationMode::InProcess), // or Auto +); +``` + +Modes: +- `Proxy` (default): always use HTTP proxy. +- `InProcess`: use direct dispatch (requires `from_rustapi`). +- `Auto`: prefer in-process when a `RustApi` was attached, fall back to proxy. + +When you use `run_rustapi_and_mcp*`, the runner automatically wires the HTTP base for the proxy path. In-process mode simply ignores it. + +## Performance + +See the benchmark in `crates/rustapi-mcp/tests/mcp_e2e.rs`. + +Typical numbers on a dev machine (1000 sequential tool calls): + +- In-process: ~28 µs per call +- Proxy (live localhost HTTP): ~1.3 ms per call +- Speedup: ~45-50x + +The in-process path still executes the full middleware chain, extractors, validation, and error handling. + +## Implementation Notes + +The `RequestDispatcher` (obtained via `app.request_dispatcher()`) clones the necessary `Arc`, `LayerStack`, and `InterceptorChain`. Your `McpServer` stores a `RequestInvoker` when created via `from_rustapi`. + +Tool calls still go through: +- Request interceptors +- Middleware layers (in order) +- Router match + handler (with extractors, Schema validation, etc.) +- Response interceptors + +No shortcuts are taken. + +## Cookbook Cross-References + +- Main MCP page: [MCP Integration (Agent Tools)](mcp_integration.md) +- CLI generator: [OpenAPI to MCP CLI](mcp_openapi_cli.md) +- stdio: [MCP stdio Transport](mcp_stdio.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/mcp_openapi_cli.md b/docs/cookbook/src/recipes/mcp_openapi_cli.md new file mode 100644 index 00000000..636e5b7e --- /dev/null +++ b/docs/cookbook/src/recipes/mcp_openapi_cli.md @@ -0,0 +1,79 @@ +# OpenAPI to MCP CLI + +The `cargo-rustapi` CLI includes `rustapi mcp generate`. It takes **any** OpenAPI 3.x spec (from FastAPI, Express, Go, Spring, etc.) and instantly spins up an MCP server. Tool calls are proxied to the real backend. + +This is a major growth feature: non-Rust teams can get first-class agent tools with zero Rust code. + +## Installation + +```bash +cargo install cargo-rustapi +# or from source +cargo install --path crates/cargo-rustapi +``` + +## Basic Usage + +```bash +# From a local spec file +rustapi mcp generate \ + --spec ./openapi.json \ + --target http://localhost:8000 \ + --port 9090 \ + --tags public,agent + +# From a URL +rustapi mcp generate \ + --url https://api.example.com/openapi.json \ + --target https://api.example.com \ + --port 9090 + +# Point at a running service (auto-fetches /openapi.json) +rustapi mcp generate \ + --api http://localhost:8080 \ + --port 9090 +``` + +## Flags + +| Flag | Description | +|-----------------------|-------------| +| `--spec ` | Local JSON or YAML OpenAPI file | +| `--url ` | Fetch spec from HTTP URL | +| `--api ` | Use `/openapi.json` as spec source + default target | +| `--target ` | Backend that tool calls proxy to (required unless `--api`) | +| `--port ` | MCP listen port (default 9090) | +| `--name ` | Server name advertised to clients | +| `--tags ` | Comma-separated tags to expose | +| `--allow-path-prefix` | Only include paths starting with prefix | +| `--stdio` | Use stdio transport instead of HTTP | + +## Example Walkthrough with a Python FastAPI + +1. Start your FastAPI app on port 8000 (it serves OpenAPI at `/openapi.json`). + +2. In another terminal: + +```bash +rustapi mcp generate --api http://localhost:8000 --port 9090 --tags public +``` + +3. Test with curl or connect Claude Desktop / Cursor to `http://localhost:9090`. + +4. Agents see your FastAPI endpoints as tools. Calls go through the real backend (auth, validation, DB, etc.). + +## How It Works + +- Uses `McpServer::from_spec(config, &parsed_openapi)`. +- Reuses the same discovery and proxy logic as native RustAPI MCP. +- No RustAPI server required on the target side. + +## Security Note + +The generated MCP server is only as secure as the filters you apply (`--tags`, `--allow-path-prefix`). Never expose internal/admin routes to agents unless you intend to. + +## Related + +- Native MCP: [MCP Integration](mcp_integration.md) +- In-process: [MCP In-Process Invocation](mcp_in_process.md) +- stdio: [MCP stdio Transport](mcp_stdio.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/mcp_stdio.md b/docs/cookbook/src/recipes/mcp_stdio.md new file mode 100644 index 00000000..7191e74b --- /dev/null +++ b/docs/cookbook/src/recipes/mcp_stdio.md @@ -0,0 +1,51 @@ +# MCP stdio Transport + +In addition to the HTTP JSON-RPC transport, `rustapi mcp generate` supports `--stdio`. + +stdio is the transport used by many local AI clients (Claude Desktop, some Cursor setups, custom agents) that spawn the MCP server as a child process and communicate over stdin/stdout. + +## Usage + +```bash +rustapi mcp generate \ + --spec ./openapi.json \ + --target http://localhost:8000 \ + --stdio +``` + +When `--stdio` is passed, the command does **not** bind a TCP port. Instead it enters a loop: + +- Reads JSON-RPC lines from stdin +- Dispatches `initialize`, `tools/list`, `tools/call` +- Writes JSON-RPC responses to stdout + +## Why stdio? + +- No network port to manage or firewall. +- Simple process model for desktop apps. +- Works well with local-only tools. + +## Current Limitations (MVP) + +- Only basic JSON-RPC over line-delimited messages (no SSE framing yet). +- No built-in auth/token for stdio (rely on OS process isolation + tool-level filters). +- Logging goes to stderr. + +## Connecting Clients + +Most clients that support "local MCP server" or "command" transport can use: + +```json +{ + "command": "rustapi", + "args": ["mcp", "generate", "--spec", "/path/to/openapi.json", "--target", "http://127.0.0.1:8000", "--stdio"] +} +``` + +See your client's documentation for exact config format. + +## Related Pages + +- Main MCP: [MCP Integration](mcp_integration.md) +- In-process: [MCP In-Process Invocation](mcp_in_process.md) +- CLI generator: [OpenAPI to MCP CLI](mcp_openapi_cli.md) \ No newline at end of file