diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b8591f..f7768c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Embedded dashboard snapshots now include request-stage counters, route topology groups, health endpoint summary, and replay admin API discovery metadata. - Dashboard UI adds route group/method/tag filters plus a replay browser that reuses the existing `ReplayLayer` admin API for list, detail, and diff workflows. - Replay admin list endpoint now accepts UI-friendly pagination and filters: `offset`, `status_max`, `from`, `to`, `tag`, and `order`. +- `mcp_tools` example under `crates/rustapi-rs/examples/` showing concurrent RustAPI HTTP + MCP sidecar with tag-based tool exposure (run with `--features protocol-mcp`). +- Comprehensive e2e tests for the MCP transport (initialize, tag-filtered `tools/list`, real proxied `tools/call` for GET+body routes, error handling for hidden tools). +- Cookbook recipe "MCP Integration (Agent Tools)" plus `rustapi_mcp.md` crate deep-dive page. ### Documentation diff --git a/Cargo.lock b/Cargo.lock index 983ba4a6..ec252a7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3277,6 +3277,28 @@ dependencies = [ "syn", ] +[[package]] +name = "rustapi-mcp" +version = "0.1.478" +dependencies = [ + "async-trait", + "bytes", + "http", + "http-body-util", + "hyper", + "hyper-util", + "reqwest", + "rustapi-core", + "rustapi-openapi", + "rustapi-rs", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "rustapi-openapi" version = "0.1.478" @@ -3300,6 +3322,7 @@ dependencies = [ "rustapi-extras", "rustapi-grpc", "rustapi-macros", + "rustapi-mcp", "rustapi-openapi", "rustapi-toon", "rustapi-validate", diff --git a/Cargo.toml b/Cargo.toml index 641ea4c3..b24beff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/rustapi-view", "crates/rustapi-testing", "crates/rustapi-grpc", + "crates/rustapi-mcp", "crates/cargo-rustapi", ] @@ -103,6 +104,7 @@ rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.335" } rustapi-view = { path = "crates/rustapi-view", version = "0.1.335" } rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.335" } rustapi-grpc = { path = "crates/rustapi-grpc", version = "0.1.335" } +rustapi-mcp = { path = "crates/rustapi-mcp", version = "0.1.335" } # HTTP/3 (QUIC) quinn = "0.11" diff --git a/README.md b/README.md index defbdf9e..bd3c546a 100644 --- a/README.md +++ b/README.md @@ -148,18 +148,25 @@ Current benchmark methodology and canonical published performance claims live in ## Quick Start +**Recommended usage** (short and clean macro paths): + +```toml +[dependencies] +api = { package = "rustapi-rs", version = "0.1.478" } +``` + ```rust -use rustapi_rs::prelude::*; +use api::prelude::*; #[derive(Serialize, Schema)] struct Message { text: String } -#[rustapi_rs::get("/hello/{name}")] +#[api::get("/hello/{name}")] async fn hello(Path(name): Path) -> Json { Json(Message { text: format!("Hello, {}!", name) }) } -#[rustapi_rs::main] +#[api::main] async fn main() -> std::result::Result<(), Box> { RustApi::auto().run("127.0.0.1:8080").await } @@ -167,12 +174,14 @@ async fn main() -> std::result::Result<(), Box **Tip:** Crate'i `api` (veya `myapi`, `server` vs.) diye alias'lamak en temiz ve FastAPI benzeri deneyimi verir. Makrolar otomatik olarak `#[api::get]`, `#[api::post]`, `#[api::main]` şeklinde çalışır. + For production deployments, you can enable standard probe endpoints without writing handlers manually: ```rust -use rustapi_rs::prelude::*; +use api::prelude::*; -#[rustapi_rs::main] +#[api::main] async fn main() -> std::result::Result<(), Box> { let health = HealthCheckBuilder::new(true) .add_check("database", || async { HealthStatus::healthy() }) @@ -193,9 +202,9 @@ This registers: Or use a single production baseline preset: ```rust -use rustapi_rs::prelude::*; +use api::prelude::*; -#[rustapi_rs::main] +#[api::main] async fn main() -> std::result::Result<(), Box> { RustApi::auto() .production_defaults("users-api") @@ -206,11 +215,13 @@ async fn main() -> std::result::Result<(), Box &'static str { "ok" } + +#[api::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto().run("127.0.0.1:8080").await +} ``` +Bu, hem daha okunabilir hem de FastAPI benzeri bir deneyim sağlar. Makro genişletme sırasında crate ismi otomatik olarak algılanır (`proc-macro-crate` sayesinde). + ## Feature Flags Features are organized into three namespaces: @@ -236,6 +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. - 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`) @@ -247,10 +266,11 @@ 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 +- [~] 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, authorization, and operational policy enforcement. + - 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. ## Documentation diff --git a/api/public/rustapi-rs.all-features.txt b/api/public/rustapi-rs.all-features.txt index 9384a99c..6905fe7e 100644 --- a/api/public/rustapi-rs.all-features.txt +++ b/api/public/rustapi-rs.all-features.txt @@ -518,6 +518,8 @@ pub use rustapi_rs::prelude::route pub use rustapi_rs::prelude::run_concurrently pub use rustapi_rs::prelude::run_rustapi_and_grpc pub use rustapi_rs::prelude::run_rustapi_and_grpc_with_shutdown +pub use rustapi_rs::prelude::run_rustapi_and_mcp +pub use rustapi_rs::prelude::run_rustapi_and_mcp_with_shutdown pub use rustapi_rs::prelude::serve_dir pub use rustapi_rs::prelude::sse_from_iter pub use rustapi_rs::prelude::sse_response @@ -529,6 +531,8 @@ pub use rustapi_rs::protocol::grpc::<> pub mod rustapi_rs::protocol::http3 pub use rustapi_rs::protocol::http3::Http3Config pub use rustapi_rs::protocol::http3::Http3Server +pub mod rustapi_rs::protocol::mcp +pub use rustapi_rs::protocol::mcp::<> pub mod rustapi_rs::protocol::toon pub use rustapi_rs::protocol::toon::<> pub mod rustapi_rs::protocol::view diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index e1944a93..fab98676 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -494,6 +494,29 @@ impl RustApi { fn mount_auto_routes_grouped(mut self) -> Self { let routes = crate::auto_route::collect_auto_routes(); + + if routes.is_empty() { + // This is a common source of confusion with linkme-based auto registration. + // We emit a clear warning so users know their annotated handlers were not linked. + tracing::warn!( + target: "rustapi::auto", + count = 0, + "RustApi::auto() collected 0 routes. \ + This usually means either:\n\ + - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\ + - The binary/test was not linked with the annotated modules (common in some test setups).\n\ + - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\ + You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()." + ); + } else { + #[cfg(feature = "tracing")] + tracing::debug!( + target: "rustapi::auto", + count = routes.len(), + "Auto route collection found handlers" + ); + } + // Use BTreeMap for deterministic route registration order let mut by_path: BTreeMap = BTreeMap::new(); diff --git a/crates/rustapi-core/src/auto_route.rs b/crates/rustapi-core/src/auto_route.rs index a0b8feba..ba59797c 100644 --- a/crates/rustapi-core/src/auto_route.rs +++ b/crates/rustapi-core/src/auto_route.rs @@ -1,37 +1,108 @@ //! Auto-route registration using linkme distributed slices //! -//! This module enables zero-config route registration. Routes decorated with -//! `#[rustapi::get]`, `#[rustapi::post]`, etc. are automatically collected at link-time. +//! **Implementation detail**: This module provides the current backend for +//! automatic, zero-boilerplate route collection using the `linkme` crate's +//! distributed slice mechanism. +//! +//! This is an internal implementation detail of `RustApi::auto()`. +//! The public contract is only: +//! - Handlers annotated with the route macros are discovered automatically. +//! - `collect_auto_routes()` and `auto_route_count()` for introspection. +//! +//! We may change the underlying mechanism in the future (while keeping the +//! observable behavior stable). +//! +//! This module enables **zero-config route registration**. Routes decorated with +//! `#[rustapi_rs::get]`, `#[rustapi_rs::post]`, etc. are automatically collected at +//! **link time** using the [`linkme`](https://docs.rs/linkme) crate. +//! +//! `RustApi::auto()` (and `RustApi::config()`) rely on this mechanism. //! //! # How It Works //! -//! When you use `#[rustapi::get("/path")]` or similar macros, they generate a -//! static registration that adds the route factory function to a distributed slice. -//! At runtime, `RustApi::auto()` collects all these routes and registers them. +//! The attribute macros emit a small static initializer that appends a factory +//! function into a `linkme::distributed_slice`. At runtime we simply iterate the +//! slice and build the router. +//! +//! After collection we sort routes into a `BTreeMap` so registration order is +//! deterministic regardless of link order. +//! +//! # Public API +//! +//! - [`collect_auto_routes`] – returns all discovered routes as `Vec` +//! - [`auto_route_count`] – cheap way to check how many handlers were linked in //! //! # Example //! //! ```rust,ignore //! use rustapi_rs::prelude::*; //! -//! #[rustapi::get("/users")] +//! #[rustapi_rs::get("/users")] //! async fn list_users() -> Json> { //! Json(vec![]) //! } //! -//! #[rustapi::post("/users")] +//! #[rustapi_rs::post("/users")] //! async fn create_user(Json(body): Json) -> Created { //! // ... //! } //! -//! #[rustapi::main] +//! #[rustapi_rs::main] //! async fn main() -> Result<()> { -//! // Routes are auto-registered, no need for manual .mount() calls! +//! // No manual .route() calls needed! //! RustApi::auto() //! .run("0.0.0.0:8080") //! .await //! } //! ``` +//! +//! # Limitations & Known Gotchas +//! +//! Link-time registration is powerful but comes with trade-offs: +//! +//! - **Tests can be flaky** — the test binary sometimes links differently than the +//! main binary. You may see `auto_route_count() == 0` inside `#[test]` even if +//! your annotated functions exist. Use `collect_auto_routes()` + filtering or +//! fall back to manual `.route()` in tests when necessary. +//! +//! - **Non-executable artifacts** (cdylib, rlib, staticlib, wasm32, etc.) often do +//! **not** populate distributed slices reliably because the linker may discard +//! the registration code. +//! +//! - **Multiple separate binaries** in the same workspace each get their own +//! independent slice. Routes defined in one binary are invisible to another. +//! +//! - There is **no runtime unregistration**. Once linked, the routes are there for +//! the lifetime of the process. +//! +//! - If you see zero routes at runtime with `RustApi::auto()`, the most common +//! causes are: +//! 1. No handlers were annotated with the route attributes. +//! 2. The module containing the handler was not linked into this binary. +//! 3. You are inside a test, library, or cdylib target. +//! +//! A clear warning is now emitted automatically when `RustApi::auto()` collects +//! zero routes (see `mount_auto_routes_grouped`). +//! +//! You can always inspect the situation with: +//! +//! ```rust,ignore +//! println!("Auto routes discovered: {}", rustapi_rs::auto_route_count()); +//! ``` +//! +//! # Manual Registration (always available) +//! +//! If auto-registration causes problems in your environment, you can ignore the +//! attribute macros entirely and use the classic builder: +//! +//! ```rust,ignore +//! RustApi::new() +//! .route("/users", get(list_users).post(create_user)) +//! .run("0.0.0.0:8080") +//! .await +//! ``` +//! +//! Both styles can be mixed freely. use crate::handler::Route; use linkme::distributed_slice; @@ -66,8 +137,18 @@ pub fn collect_auto_routes() -> Vec { /// Get the count of auto-registered routes without collecting them. /// -/// Useful for debugging and logging. -#[allow(dead_code)] +/// This is useful for: +/// - Debugging (e.g. in tests or startup logs) +/// - Asserting that your annotated handlers were actually linked in +/// +/// # Example +/// +/// ```rust,ignore +/// let count = rustapi_core::auto_route_count(); +/// if count == 0 { +/// eprintln!("Warning: No auto-routes were discovered!"); +/// } +/// ``` pub fn auto_route_count() -> usize { AUTO_ROUTES.len() } @@ -78,15 +159,25 @@ mod tests { #[test] fn test_auto_routes_slice_exists() { - // The slice should exist, even if empty initially + // The slice always exists (even if empty). This test mainly ensures + // the linkme static was emitted correctly by the build. let _count = auto_route_count(); } #[test] - fn test_collect_auto_routes() { - // Should not panic, returns empty vec if no routes registered + fn test_collect_auto_routes_does_not_panic() { + // Collection must be safe even when no annotated handlers are present + // in the current test binary (very common situation). let routes = collect_auto_routes(); - // In tests, we may or may not have routes depending on what's linked + // We don't assert a specific count here because linkme behavior in + // test binaries is not guaranteed to be the same as in the final binary. let _ = routes; } + + #[test] + fn test_auto_route_count_is_accessible() { + // Public API smoke test – users and integration tests should be able + // to call this without importing internal modules. + let _ = auto_route_count(); + } } diff --git a/crates/rustapi-core/src/auto_schema.rs b/crates/rustapi-core/src/auto_schema.rs index b9eabc1d..237e4672 100644 --- a/crates/rustapi-core/src/auto_schema.rs +++ b/crates/rustapi-core/src/auto_schema.rs @@ -1,5 +1,13 @@ //! Auto-schema registration using linkme distributed slices //! +//! **Implementation detail**: Companion to `auto_route`. +//! Provides link-time registration of types that should appear in the generated +//! OpenAPI spec (via `#[rustapi_rs::schema]` and implicit schema derivation). +//! +//! This is an internal detail. The only stable surface is that schemas referenced +//! by auto-registered routes (and explicitly annotated types) end up in the +//! OpenAPI document when using `RustApi::auto()`. +//! //! This module enables zero-config OpenAPI schema registration. //! Route macros can register schemas at link-time, and `RustApi::auto()` //! will collect and apply them before serving docs. diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 3c50ee5e..90997716 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -52,7 +52,7 @@ mod app; mod auto_route; -pub use auto_route::collect_auto_routes; +pub use auto_route::{auto_route_count, collect_auto_routes}; mod auto_schema; pub use auto_schema::apply_auto_schemas; #[cfg(feature = "dashboard")] @@ -88,13 +88,34 @@ mod tracing_macros; /// Private module for macro internals - DO NOT USE DIRECTLY /// -/// This module is used by procedural macros to register routes. -/// It is not part of the public API and may change at any time. +/// This module exists **exclusively** to support the `rustapi-macros` crate. +/// +/// It re-exports: +/// - `linkme` (for `#[distributed_slice]` attributes generated by our route macros) +/// - The two distributed slices (`AUTO_ROUTES` and `AUTO_SCHEMAS`) +/// - Other internal crates needed by the generated code +/// +/// **Nothing in this module is part of the public API.** It is subject to change +/// without notice, including the decision to keep using `linkme` or switch to +/// a different registration mechanism in the future. +/// +/// External crates should only ever depend on the public surface of `rustapi-core` +/// (or preferably `rustapi-rs`). #[doc(hidden)] pub mod __private { + /// Re-export of the `linkme` crate. + /// + /// Used by generated code to emit: + /// ```ignore + /// #[linkme::distributed_slice(...)] + /// #[linkme(crate = ...)] + /// ``` + /// This enables link-time collection of routes and OpenAPI schemas. + pub use linkme; + pub use crate::auto_route::AUTO_ROUTES; pub use crate::auto_schema::AUTO_SCHEMAS; - pub use linkme; + pub use rustapi_openapi; pub use rustapi_validate; } diff --git a/crates/rustapi-macros/src/lib.rs b/crates/rustapi-macros/src/lib.rs index a327150e..a2b161ec 100644 --- a/crates/rustapi-macros/src/lib.rs +++ b/crates/rustapi-macros/src/lib.rs @@ -114,6 +114,7 @@ pub fn schema(_attr: TokenStream, item: TokenStream) -> TokenStream { #input #[allow(non_upper_case_globals)] + // Schema registration via linkme (for the `#[schema]` attribute on types) #[#rustapi_path::__private::linkme::distributed_slice(#rustapi_path::__private::AUTO_SCHEMAS)] #[linkme(crate = #rustapi_path::__private::linkme)] static #registrar_ident: fn(&mut #rustapi_path::__private::openapi::OpenApiSpec) = @@ -784,7 +785,9 @@ fn generate_route_handler(method: &str, attr: TokenStream, item: TokenStream) -> #chained_calls } - // Auto-register route with linkme + // Auto-register this route factory using linkme distributed slices. + // The `#[linkme(crate = ...)]` attribute is required for correct + // operation when the user renames the `rustapi-rs` crate. #[doc(hidden)] #[allow(non_upper_case_globals)] #[#rustapi_path::__private::linkme::distributed_slice(#rustapi_path::__private::AUTO_ROUTES)] @@ -798,8 +801,12 @@ fn generate_route_handler(method: &str, attr: TokenStream, item: TokenStream) -> #( spec.register_in_place::<#schema_types>(); )* } + // Auto-register schema population function (linkme). + // See the route registration above for why the `#[linkme(crate = ...)]` + // attribute is present. #[doc(hidden)] #[allow(non_upper_case_globals)] + // Schema registration via linkme (for the `#[schema]` attribute on types) #[#rustapi_path::__private::linkme::distributed_slice(#rustapi_path::__private::AUTO_SCHEMAS)] #[linkme(crate = #rustapi_path::__private::linkme)] static #auto_schema_name: fn(&mut #rustapi_path::__private::openapi::OpenApiSpec) = #schema_reg_fn_name; diff --git a/crates/rustapi-macros/tests/renamed_dependency_support.rs b/crates/rustapi-macros/tests/renamed_dependency_support.rs index 630d9111..a6115316 100644 --- a/crates/rustapi-macros/tests/renamed_dependency_support.rs +++ b/crates/rustapi-macros/tests/renamed_dependency_support.rs @@ -26,6 +26,10 @@ struct AliasPath { #[test] fn renamed_dependency_supports_route_macros() { + // This test proves that the linkme registration works even when the user + // renames the crate (e.g. `api = { package = "rustapi-rs" }`). + // The macros use `proc-macro-crate` + explicit `#[linkme(crate = ...)]` + // to emit the correct path. let routes = rustapi_alias::collect_auto_routes(); assert!( routes diff --git a/crates/rustapi-mcp/Cargo.toml b/crates/rustapi-mcp/Cargo.toml new file mode 100644 index 00000000..856aeb24 --- /dev/null +++ b/crates/rustapi-mcp/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "rustapi-mcp" +description = "Native Model Context Protocol (MCP) support for RustAPI - expose your endpoints as tools for LLMs and AI agents" +documentation = "https://docs.rs/rustapi-mcp" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["web", "framework", "api", "mcp", "llm", "ai", "agents"] +categories = ["web-programming::http-server", "network-programming"] +rust-version.workspace = true +readme = "README.md" + +[dependencies] +# Internal RustAPI crates +rustapi-core = { workspace = true } +rustapi-openapi = { workspace = true } + +# Async & runtime +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +async-trait = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# HTTP / transport (for future SSE + HTTP MCP transport) +http = { workspace = true } +http-body-util = { workspace = true } +bytes = { workspace = true } +hyper = { workspace = true, features = ["server", "http1"] } +hyper-util = { workspace = true, features = ["tokio"] } + +# Utilities +tracing = { workspace = true } +thiserror = "1.0" +uuid = { workspace = true, features = ["v4"] } + +# For MCP tool invocation proxy (calls back into the main HTTP API over localhost) +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +rustapi-rs = { workspace = true, features = ["protocol-mcp"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { workspace = true, features = ["derive"] } diff --git a/crates/rustapi-mcp/src/config.rs b/crates/rustapi-mcp/src/config.rs new file mode 100644 index 00000000..fbce8bc3 --- /dev/null +++ b/crates/rustapi-mcp/src/config.rs @@ -0,0 +1,127 @@ +//! Configuration for the MCP server / integration. + +use std::collections::HashSet; + +/// Configuration for the native MCP server. +/// +/// This is the primary way users control what gets exposed as tools, +/// authentication for MCP clients, transport behavior, etc. +#[derive(Debug, Clone)] +pub struct McpConfig { + /// Human-friendly name of this MCP server (shown to agents). + pub name: String, + /// Version string. + pub version: String, + /// Optional description. + pub description: Option, + + /// Whether tool discovery and calling is enabled. + pub tools_enabled: bool, + + /// Explicitly allowed tags. Only routes that have at least one of these tags + /// (via OpenAPI `tags` or future route metadata) will be exposed as tools. + /// + /// Empty set + no other allow rules = nothing is exposed (safe default). + pub allowed_tags: HashSet, + + /// Explicit path prefixes that are allowed to become tools. + /// Example: `["/api/public", "/agent"]` + pub allowed_path_prefixes: Vec, + + /// Admin / MCP client token. + /// + /// When set, MCP clients must present this (via header or query param, + /// transport dependent) to use discovery or invocation. + pub admin_token: Option, + + /// Whether to include detailed error information in tool responses. + /// In production you usually want this `false` (similar to RUSTAPI_ENV=production). + pub expose_detailed_errors: bool, + + /// Maximum number of tools to advertise in one `tools/list` response. + /// Helps protect against very large route sets. + pub max_tools: usize, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + name: "rustapi-mcp".to_string(), + version: "0.0.0".to_string(), + description: None, + tools_enabled: true, + allowed_tags: HashSet::new(), + allowed_path_prefixes: vec![], + admin_token: None, + expose_detailed_errors: false, + max_tools: 256, + } + } +} + +impl McpConfig { + /// Create a new config with reasonable defaults. + pub fn new() -> Self { + Self::default() + } + + /// Set the name advertised to MCP clients. + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Set the version advertised to MCP clients. + pub fn version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + /// Set a human description. + pub fn description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Enable or disable the tools capability entirely. + pub fn enable_tools(mut self, enabled: bool) -> Self { + self.tools_enabled = enabled; + self + } + + /// Allow tools only for routes that carry at least one of the given tags. + /// + /// This is the recommended way to safely expose a curated surface to agents. + pub fn allowed_tags(mut self, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_tags = tags.into_iter().map(Into::into).collect(); + self + } + + /// Add a path prefix that is allowed to be exposed as tools. + pub fn allow_path_prefix(mut self, prefix: impl Into) -> Self { + self.allowed_path_prefixes.push(prefix.into()); + self + } + + /// Require this token for MCP clients (discovery + calls). + pub fn admin_token(mut self, token: impl Into) -> Self { + self.admin_token = Some(token.into()); + self + } + + /// Control whether tool responses include full internal error details. + pub fn expose_detailed_errors(mut self, expose: bool) -> Self { + self.expose_detailed_errors = expose; + self + } + + /// Set the maximum number of tools to list. + pub fn max_tools(mut self, max: usize) -> Self { + self.max_tools = max; + self + } +} diff --git a/crates/rustapi-mcp/src/discovery.rs b/crates/rustapi-mcp/src/discovery.rs new file mode 100644 index 00000000..98fc6af1 --- /dev/null +++ b/crates/rustapi-mcp/src/discovery.rs @@ -0,0 +1,329 @@ +//! Tool discovery: converting RustAPI OpenAPI metadata into MCP tools. +//! +//! This module walks the OpenAPI spec produced by RustApi and turns +//! HTTP operations into MCP `McpTool` definitions, applying exposure +//! filters from `McpConfig`. + +use crate::config::McpConfig; +use crate::types::McpTool; +use rustapi_openapi::{Components, OpenApiSpec, Operation, Parameter, RequestBody, SchemaRef}; +use std::collections::BTreeMap; + +/// Main entry point: extract a filtered list of MCP tools from an OpenAPI spec. +pub fn extract_tools_from_spec(spec: &OpenApiSpec, config: &McpConfig) -> Vec { + if !config.tools_enabled { + return vec![]; + } + + let mut tools = Vec::new(); + let components = spec.components.as_ref(); + + for (path, path_item) in &spec.paths { + // Apply path prefix filter if configured + if !path_matches_prefixes(path, &config.allowed_path_prefixes) { + continue; + } + + // Check each HTTP method + if let Some(op) = &path_item.get { + if let Some(tool) = operation_to_tool("GET", path, op, components, config) { + tools.push(tool); + } + } + if let Some(op) = &path_item.post { + if let Some(tool) = operation_to_tool("POST", path, op, components, config) { + tools.push(tool); + } + } + if let Some(op) = &path_item.put { + if let Some(tool) = operation_to_tool("PUT", path, op, components, config) { + tools.push(tool); + } + } + if let Some(op) = &path_item.patch { + if let Some(tool) = operation_to_tool("PATCH", path, op, components, config) { + tools.push(tool); + } + } + if let Some(op) = &path_item.delete { + if let Some(tool) = operation_to_tool("DELETE", path, op, components, config) { + tools.push(tool); + } + } + + // We can add more methods later if needed (HEAD, OPTIONS...) + + if tools.len() >= config.max_tools { + break; + } + } + + // Enforce max_tools + if tools.len() > config.max_tools { + tools.truncate(config.max_tools); + } + + tools +} + +fn path_matches_prefixes(path: &str, prefixes: &[String]) -> bool { + if prefixes.is_empty() { + return true; + } + prefixes.iter().any(|p| path.starts_with(p)) +} + +fn operation_to_tool( + method: &str, + path: &str, + op: &Operation, + components: Option<&Components>, + config: &McpConfig, +) -> Option { + // Tag filtering (if allowed_tags configured, operation must have at least one matching tag) + if !config.allowed_tags.is_empty() { + let has_match = op.tags.iter().any(|t| config.allowed_tags.contains(t)); + if !has_match { + return None; + } + } + + let name = generate_tool_name(method, path, op); + let description = op + .summary + .clone() + .or_else(|| op.description.clone()); + + let input_schema = build_input_schema(op, components); + + Some(McpTool { + name, + description, + input_schema, + output_schema: None, // Future: extract from success responses + tags: op.tags.clone(), + }) +} + +/// Generate a stable, agent-friendly tool name. +fn generate_tool_name(method: &str, path: &str, op: &Operation) -> String { + if let Some(oid) = &op.operation_id { + return sanitize_name(oid); + } + + // Fallback: method + sanitized path + let mut slug = path + .trim_start_matches('/') + .replace(['/', '{', '}', ':'], "_") + .replace(['-', '.', ' '], "_"); + + // Collapse multiple underscores + while slug.contains("__") { + slug = slug.replace("__", "_"); + } + + let slug = slug.trim_matches('_').to_string(); + let method_lower = method.to_lowercase(); + + if slug.is_empty() { + method_lower + } else { + format!("{}_{}", method_lower, slug) + } +} + +fn sanitize_name(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect::() + .trim_matches('_') + .to_string() + .to_lowercase() +} + +/// Build a JSON Schema for the tool input. +/// +/// Strategy (MVP): +/// - If there is a JSON request body, use its schema (attempt simple $ref resolution). +/// - Otherwise, synthesize an object schema from the operation's parameters. +fn build_input_schema(op: &Operation, components: Option<&Components>) -> serde_json::Value { + // 1. Try request body first (most common for "tool call with data") + if let Some(body) = &op.request_body { + if let Some(schema_val) = extract_json_schema_from_body(body, components) { + return schema_val; + } + } + + // 2. Fallback: build from parameters (path + query + header) + build_schema_from_parameters(&op.parameters, components) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustapi_openapi::{OpenApiSpec, Operation}; + + fn make_minimal_spec() -> OpenApiSpec { + let mut spec = OpenApiSpec::new("Test API", "1.0.0"); + + // Public tool + let mut get_user = Operation::new(); + get_user.summary = Some("Get user by ID".to_string()); + get_user.tags = vec!["users".to_string(), "public".to_string()]; + get_user.operation_id = Some("getUser".to_string()); + + // Body tool + let mut create_user = Operation::new(); + create_user.summary = Some("Create a user".to_string()); + create_user.tags = vec!["users".to_string()]; + create_user.operation_id = Some("createUser".to_string()); + + // Internal only (no public tag) + let mut admin = Operation::new(); + admin.summary = Some("Admin only".to_string()); + admin.tags = vec!["admin".to_string()]; + + spec = spec + .path("/users/{id}", "GET", get_user) + .path("/users", "POST", create_user) + .path("/admin/users", "GET", admin); + + spec + } + + #[test] + fn extracts_tools_with_operation_id_as_name() { + let spec = make_minimal_spec(); + let config = McpConfig::new(); + + let tools = extract_tools_from_spec(&spec, &config); + assert!(!tools.is_empty()); + + let names: Vec<_> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"getuser")); + assert!(names.contains(&"createuser")); + } + + #[test] + fn respects_allowed_tags_filter() { + let spec = make_minimal_spec(); + + let config = McpConfig::new().allowed_tags(["public"]); + + let tools = extract_tools_from_spec(&spec, &config); + let _tags: Vec> = tools.iter().map(|t| t.tags.clone()).collect(); + + // Only the GET /users/{id} should survive (has "public") + assert_eq!(tools.len(), 1); + assert!(tools[0].name.contains("getuser") || tools[0].tags.contains(&"public".to_string())); + } + + #[test] + fn respects_path_prefix_filter() { + let spec = make_minimal_spec(); + + let config = McpConfig::new().allow_path_prefix("/users"); + + let tools = extract_tools_from_spec(&spec, &config); + // admin path should be filtered out + assert!(tools.iter().all(|t| !t.name.contains("admin"))); + } + + #[test] + fn max_tools_limit_is_respected() { + let spec = make_minimal_spec(); + let config = McpConfig::new().max_tools(1); + + let tools = extract_tools_from_spec(&spec, &config); + assert!(tools.len() <= 1); + } +} + +fn extract_json_schema_from_body( + body: &RequestBody, + components: Option<&Components>, +) -> Option { + // Prefer application/json + let media = body + .content + .get("application/json") + .or_else(|| body.content.values().next())?; + + if let Some(schema_ref) = &media.schema { + return Some(schema_ref_to_json(schema_ref, components)); + } + None +} + +fn build_schema_from_parameters( + params: &[Parameter], + components: Option<&Components>, +) -> serde_json::Value { + if params.is_empty() { + return serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }); + } + + let mut properties = BTreeMap::new(); + let mut required = Vec::new(); + + for param in params { + let name = param.name.clone(); + let schema = if let Some(s) = ¶m.schema { + schema_ref_to_json(s, components) + } else { + // Default to string if no schema + serde_json::json!({"type": "string"}) + }; + + if param.required { + required.push(name.clone()); + } + + properties.insert(name, schema); + } + + let mut schema = serde_json::json!({ + "type": "object", + "properties": properties, + }); + + if !required.is_empty() { + schema["required"] = serde_json::to_value(required).unwrap(); + } + schema["additionalProperties"] = serde_json::json!(false); + + schema +} + +/// Convert a SchemaRef into a plain JSON value suitable for MCP tool inputSchema. +/// For $ref we attempt a shallow resolution from components.schemas when available. +fn schema_ref_to_json( + schema_ref: &SchemaRef, + components: Option<&Components>, +) -> serde_json::Value { + match schema_ref { + SchemaRef::Ref { reference } => { + // Try to resolve simple "#/components/schemas/Name" + if let Some(name) = reference.strip_prefix("#/components/schemas/") { + if let Some(components) = components { + if let Some(schema) = components.schemas.get(name) { + // Serialize the JsonSchema2020 as value (it will be a valid schema) + return serde_json::to_value(schema).unwrap_or_else(|_| { + serde_json::json!({ "$ref": reference }) + }); + } + } + } + // Can't resolve — emit the ref (MCP clients / LLMs can sometimes handle it, or we improve later) + serde_json::json!({ "$ref": reference }) + } + SchemaRef::Schema(boxed) => { + serde_json::to_value(boxed.as_ref()).unwrap_or(serde_json::json!({})) + } + SchemaRef::Inline(val) => val.clone(), + } +} diff --git a/crates/rustapi-mcp/src/error.rs b/crates/rustapi-mcp/src/error.rs new file mode 100644 index 00000000..73e51a29 --- /dev/null +++ b/crates/rustapi-mcp/src/error.rs @@ -0,0 +1,51 @@ +//! MCP-specific error types. + +use thiserror::Error; + +/// Result alias used throughout the `rustapi-mcp` crate. +pub type Result = std::result::Result; + +/// Top-level error type for MCP operations. +#[derive(Debug, Error)] +pub enum McpError { + /// The MCP client is not authorized (missing or bad token). + #[error("unauthorized: {0}")] + Unauthorized(String), + + /// The requested capability (e.g. tools) is not enabled in config. + #[error("capability not enabled: {0}")] + CapabilityNotEnabled(String), + + /// A tool with the given name was not found / not exposed. + #[error("tool not found: {0}")] + ToolNotFound(String), + + /// Invalid request from the MCP client (bad parameters, schema mismatch, etc.). + #[error("invalid request: {0}")] + InvalidRequest(String), + + /// An error occurred while executing the underlying RustAPI handler. + /// The inner value is the stringified error (respecting redaction rules). + #[error("tool execution failed: {0}")] + ToolExecution(String), + + /// Transport-level or protocol-level error. + #[error("transport error: {0}")] + Transport(String), + + /// Internal / unexpected error. + #[error("internal mcp error: {0}")] + Internal(String), +} + +impl McpError { + /// Create an unauthorized error. + pub fn unauthorized(msg: impl Into) -> Self { + McpError::Unauthorized(msg.into()) + } + + /// Create an invalid request error. + pub fn invalid_request(msg: impl Into) -> Self { + McpError::InvalidRequest(msg.into()) + } +} diff --git a/crates/rustapi-mcp/src/lib.rs b/crates/rustapi-mcp/src/lib.rs new file mode 100644 index 00000000..ac104894 --- /dev/null +++ b/crates/rustapi-mcp/src/lib.rs @@ -0,0 +1,99 @@ +//! # rustapi-mcp +//! +//! Native [Model Context Protocol (MCP)](https://modelcontextprotocol.io) support for RustAPI. +//! +//! This crate turns your RustAPI application into a first-class tool provider for LLMs +//! and external AI agents (Claude, custom agent runtimes, etc.). +//! +//! ## Philosophy +//! +//! - **Embedded & opt-in**: Enable with the `protocol-mcp` feature. +//! - **Zero duplication**: Tool definitions are derived from your existing routes, +//! `#[derive(Schema)]` types, and OpenAPI metadata. +//! - **Security first**: Nothing is exposed as a tool unless you explicitly allow it +//! (tags, paths, or manual registration). +//! - **Respect the pipeline**: Every tool invocation goes through your normal middleware, +//! interceptors, extractors, validation, and error handling. No secret bypass paths. +//! +//! ## Current Status +//! +//! **Native MCP is implemented and functional** (discovery + real invocation + transport + concurrent runner). +//! +//! - Automatic tool discovery from your `#[rustapi_rs::get(...)]` routes + `#[derive(Schema)]` via OpenAPI. +//! - Full respect for tags (`allowed_tags`) and path prefixes for safe exposure. +//! - Sidecar HTTP server speaking minimal MCP JSON-RPC (initialize, tools/list, tools/call). +//! - Real `tools/call` execution: calls are proxied to your main RustAPI HTTP server → every layer, interceptor, extractor, validator, and error handler runs exactly as for normal traffic. +//! - `run_rustapi_and_mcp` (and with shutdown) helpers to run your API + MCP endpoint side-by-side (auto-configures proxying). +//! +//! See `memories/native_mcp_orchestration_plan.md` for the original roadmap. Invocation currently uses a localhost proxy (correct & simple). An in-process `RequestInvoker` can be added later for zero network overhead. +//! +//! ## Quick Example +//! +//! ```rust,ignore +//! use rustapi_rs::prelude::*; +//! use rustapi_rs::protocol::mcp::{McpConfig, McpServer, run_rustapi_and_mcp}; +//! +//! #[rustapi_rs::get("/weather/{city}")] +//! #[rustapi_rs::tag("public")] +//! async fn get_weather(Path(city): Path) -> Json { +//! // ... +//! } +//! +//! let app = RustApi::auto(); +//! +//! let mcp = McpServer::from_rustapi( +//! &app, +//! McpConfig::new() +//! .name("weather-agent") +//! .allowed_tags(["public"]), +//! ); +//! +//! // Runs your normal HTTP API on :8080 and MCP server on :9090. +//! // tool calls are automatically proxied back into the main API (full stack). +//! run_rustapi_and_mcp(app, "0.0.0.0:8080", mcp, "0.0.0.0:9090").await?; +//! ``` + +#![warn(missing_docs)] +#![warn(rustdoc::missing_crate_level_docs)] +#![deny(clippy::unwrap_used)] // encourage proper error handling from day one + +pub mod config; +pub(crate) mod discovery; +pub mod error; +pub mod runner; +pub mod server; +pub mod types; + +// Re-export the most important items at the crate root for convenience. +pub use config::McpConfig; +pub use error::{McpError, Result}; +pub use runner::{ + run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, BoxError, +}; +pub use server::McpServer; +pub use types::{McpCapability, McpTool, ToolCallRequest, ToolCallResponse}; + +// Re-export OpenApiSpec for ergonomic attachment: users can pass app.openapi_spec().clone() +pub use rustapi_openapi::OpenApiSpec; + +/// Prelude for common MCP types. +pub mod prelude { + pub use crate::config::McpConfig; + pub use crate::error::{McpError, Result}; + pub use crate::runner::{ + run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, + }; + pub use crate::server::McpServer; + pub use crate::types::{McpTool, ToolCallRequest, ToolCallResponse}; +} + +/// Internal module for future transport implementations (HTTP+SSE, stdio, etc.). +pub(crate) mod transport { + // Will contain SSE framing, JSON-RPC handling, etc. +} + +/// 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. +} diff --git a/crates/rustapi-mcp/src/runner.rs b/crates/rustapi-mcp/src/runner.rs new file mode 100644 index 00000000..001429c4 --- /dev/null +++ b/crates/rustapi-mcp/src/runner.rs @@ -0,0 +1,141 @@ +//! Concurrent execution helpers to run a normal RustAPI HTTP server +//! side-by-side with an MCP server (on a separate address). +//! +//! This is modeled after `rustapi-grpc` for consistency. + +use crate::server::McpServer; +use rustapi_core::RustApi; +use std::error::Error; +use std::future::Future; +use std::pin::Pin; +use tokio::sync::watch; + +/// Boxed error type used by runner helpers. +pub type BoxError = Box; + +/// Result type for runner helpers. +pub type Result = std::result::Result; + +/// Shutdown future type. +pub type ShutdownFuture = Pin + Send + 'static>>; + +fn to_boxed_error(err: E) -> BoxError +where + E: Error + Send + Sync + 'static, +{ + Box::new(err) +} + +/// Run two independent futures concurrently (HTTP + MCP). +pub async fn run_concurrently(http_future: HF, mcp_future: MF) -> Result<()> +where + HF: Future> + Send, + MF: Future> + Send, + HE: Error + Send + Sync + 'static, + ME: Error + Send + Sync + 'static, +{ + let http_task = async move { http_future.await.map_err(to_boxed_error) }; + let mcp_task = async move { mcp_future.await.map_err(to_boxed_error) }; + + let (_http_ok, _mcp_ok) = tokio::try_join!(http_task, mcp_task)?; + Ok(()) +} + +/// Run a `RustApi` HTTP server and an `McpServer` side-by-side on separate addresses. +/// +/// This is the primary helper for running your normal API and the MCP endpoint +/// (for LLM/agent clients) at the same time. +pub async fn run_rustapi_and_mcp( + app: RustApi, + http_addr: impl AsRef, + mcp: McpServer, + mcp_addr: impl AsRef, +) -> Result<()> +where +{ + let http_addr = http_addr.as_ref().to_string(); + let mcp_addr = mcp_addr.as_ref().to_string(); + + // Automatically configure the MCP server to proxy tool calls back to the main HTTP API. + // This makes end-to-end tool invocation work out of the box. + let http_base = format!("http://127.0.0.1:{}", extract_port_or_default(&http_addr, 8080)); + let mcp = mcp.with_http_base(http_base); + + let http_task = async move { app.run(&http_addr).await }; + let mcp_task = async move { mcp.serve(&mcp_addr).await.map_err(to_boxed_error) }; + + let (_http_ok, _mcp_ok) = tokio::try_join!(http_task, mcp_task)?; + Ok(()) +} + +/// Run RustAPI HTTP + MCP with a shared shutdown signal. +/// +/// Useful with `tokio::signal::ctrl_c()` so that Ctrl+C stops both servers cleanly. +pub async fn run_rustapi_and_mcp_with_shutdown( + app: RustApi, + http_addr: impl AsRef, + mcp: McpServer, + mcp_addr: impl AsRef, + shutdown_signal: SF, +) -> Result<()> +where + SF: Future + Send + 'static, +{ + let http_addr = http_addr.as_ref().to_string(); + let mcp_addr = mcp_addr.as_ref().to_string(); + + let http_base = format!("http://127.0.0.1:{}", extract_port_or_default(&http_addr, 8080)); + let mcp = mcp.with_http_base(http_base); + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let shutdown_dispatch = tokio::spawn(async move { + shutdown_signal.await; + let _ = shutdown_tx.send(true); + }); + + let http_shutdown = shutdown_notifier(shutdown_rx.clone()); + let mcp_shutdown = shutdown_notifier(shutdown_rx); + + let http_task = async move { + app.run_with_shutdown(&http_addr, http_shutdown).await + }; + + let mcp_task = async move { + mcp.serve_with_shutdown(&mcp_addr, mcp_shutdown) + .await + .map_err(to_boxed_error) + }; + + let joined = tokio::try_join!(http_task, mcp_task).map(|_| ()); + + shutdown_dispatch.abort(); + let _ = shutdown_dispatch.await; + + joined +} + +async fn shutdown_notifier(mut rx: watch::Receiver) { + if *rx.borrow() { + return; + } + while rx.changed().await.is_ok() { + if *rx.borrow() { + break; + } + } +} + +/// Very small helper to turn "0.0.0.0:9090" or "[::]:9090" into a port for the localhost base URL. +fn extract_port_or_default(addr: &str, default: u16) -> u16 { + // Try to find the last ':' and parse what follows as port + if let Some(colon) = addr.rfind(':') { + let after = &addr[colon + 1..]; + // strip any trailing path or query (shouldn't be there for addr) + let port_str = after.split(|c: char| !c.is_ascii_digit()).next().unwrap_or(""); + if let Ok(p) = port_str.parse::() { + return p; + } + } + default +} diff --git a/crates/rustapi-mcp/src/server.rs b/crates/rustapi-mcp/src/server.rs new file mode 100644 index 00000000..3812dc3c --- /dev/null +++ b/crates/rustapi-mcp/src/server.rs @@ -0,0 +1,559 @@ +//! The main `McpServer` type and lifecycle. + +use crate::config::McpConfig; +use crate::discovery; +use crate::error::{McpError, Result}; +use crate::types::{McpCapability, McpTool}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::Response; +use hyper_util::rt::TokioIo; +use rustapi_core::RustApi; +use rustapi_openapi::OpenApiSpec; +use std::convert::Infallible; +use std::future::Future; +use std::net::SocketAddr; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::net::TcpListener; +use tracing::{error, info}; + +/// The main handle for the MCP integration. +/// +/// `McpServer` can be attached to a `RustApi` instance (via its OpenAPI spec) +/// to automatically discover tools. Tool invocations (in later milestones) +/// will be driven through the normal RustAPI handler pipeline. +#[derive(Debug, Clone)] +pub struct McpServer { + config: Arc, + /// Attached OpenAPI spec used for tool discovery. + openapi: Option, + /// Base URL of the main RustAPI HTTP server (for proxy-style tool invocation over localhost). + /// Example: "http://127.0.0.1:8080" + http_base: Option, + /// 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, +} + +/// Internal info needed to turn an MCP tool/call into an actual HTTP request +/// against the main API. +#[derive(Debug, Clone)] +struct ToolExecutionInfo { + /// Original path template, e.g. "/users/{id}" + path_template: String, + /// HTTP method as string, e.g. "GET", "POST" + method: String, +} + +impl McpServer { + /// Create a new MCP server with the given configuration. + pub fn new(config: McpConfig) -> Self { + Self { + config: Arc::new(config), + openapi: None, + http_base: None, + tool_map: HashMap::new(), + } + } + + /// Create an MCP server pre-attached to a `RustApi` instance. + /// + /// This is the most ergonomic way when you already have a built `RustApi`. + 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); + server + } + + /// Create from an explicit OpenAPI spec. + pub fn from_spec(config: McpConfig, spec: &OpenApiSpec) -> Self { + let mut server = Self::new(config); + server.openapi = Some(spec.clone()); + server.rebuild_tool_map(spec); + server + } + + /// Attach (or replace) the OpenAPI spec used for discovery. + /// + /// Call this after `RustApi::auto()` / builder if you built the app first. + pub fn with_openapi(mut self, spec: OpenApiSpec) -> Self { + self.rebuild_tool_map(&spec); + self.openapi = Some(spec); + self + } + + /// Configure the base URL of the main RustAPI HTTP server. + /// + /// When set, `tools/call` will proxy the call over HTTP to this base + /// (typically "http://127.0.0.1:8080" when using the concurrent runner). + /// This guarantees that tool invocations go through the exact same + /// middleware, auth, validation, and handler code as normal traffic. + pub fn with_http_base(mut self, base: impl Into) -> Self { + self.http_base = Some(base.into()); + self + } + + /// Rebuild the internal tool_name -> execution info map from the OpenAPI spec. + /// Called automatically when attaching a spec. + fn rebuild_tool_map(&mut self, spec: &OpenApiSpec) { + self.tool_map.clear(); + + let config = &*self.config; + + let insert_tool = |map: &mut HashMap, path: &str, method: &str, op: &rustapi_openapi::Operation| { + if !config.allowed_tags.is_empty() { + let has_match = op.tags.iter().any(|t| config.allowed_tags.contains(t)); + if !has_match { + return; + } + } + + let name = generate_tool_name(method, path, op); + let info = ToolExecutionInfo { + path_template: path.to_string(), + method: method.to_string(), + }; + map.insert(name, info); + }; + + for (path, path_item) in &spec.paths { + if !path_matches_prefixes(path, &config.allowed_path_prefixes) { + continue; + } + + if let Some(op) = &path_item.get { + insert_tool(&mut self.tool_map, path, "GET", op); + } + if let Some(op) = &path_item.post { + insert_tool(&mut self.tool_map, path, "POST", op); + } + if let Some(op) = &path_item.put { + insert_tool(&mut self.tool_map, path, "PUT", op); + } + if let Some(op) = &path_item.patch { + insert_tool(&mut self.tool_map, path, "PATCH", op); + } + if let Some(op) = &path_item.delete { + insert_tool(&mut self.tool_map, path, "DELETE", op); + } + } + } + + /// Get a reference to the active configuration. + pub fn config(&self) -> &McpConfig { + &self.config + } + + /// Return the capabilities this server currently advertises. + pub fn capabilities(&self) -> Vec { + let mut caps = vec![]; + + if self.config.tools_enabled { + caps.push(McpCapability::Tools); + } + + caps + } + + /// Perform the `initialize` handshake. + /// + /// MCP clients call this first. Returns server info + supported capabilities. + pub fn initialize(&self) -> InitializeResult { + InitializeResult { + name: self.config.name.clone(), + version: self.config.version.clone(), + description: self.config.description.clone(), + capabilities: self.capabilities(), + } + } + + /// Discover and return the list of tools that should be exposed to MCP clients. + /// + /// Tools are derived from the attached OpenAPI spec (from `RustApi`), filtered + /// according to `McpConfig::allowed_tags` and `allowed_path_prefixes`. + pub async fn list_tools(&self) -> Result> { + if !self.config.tools_enabled { + return Err(crate::error::McpError::CapabilityNotEnabled( + "tools".to_string(), + )); + } + + if let Some(spec) = &self.openapi { + let tools = discovery::extract_tools_from_spec(spec, &self.config); + Ok(tools) + } else { + // No spec attached → no tools (safe default) + Ok(vec![]) + } + } + + /// Execute a tool call by proxying it as a real HTTP request to the main + /// RustAPI server (using the configured `http_base`). + /// + /// This is the heart of Native MCP: the call goes through your normal + /// layers, interceptors, extractors, validation, error handling, etc. + pub async fn call_tool( + &self, + req: crate::types::ToolCallRequest, + ) -> Result { + let info = self.tool_map.get(&req.name).ok_or_else(|| { + McpError::ToolNotFound(format!("no tool registered with name '{}'", req.name)) + })?; + + let path = substitute_path_params(&info.path_template, &req.arguments); + + let base = self + .http_base + .as_deref() + .unwrap_or("http://127.0.0.1:8080"); + + let url = format!("{}{}", base.trim_end_matches('/'), path); + + let method = reqwest::Method::from_bytes(info.method.as_bytes()) + .unwrap_or(reqwest::Method::GET); + + let client = reqwest::Client::new(); + + let mut request_builder = client.request(method.clone(), &url); + + // If this looks like a mutating method, send the arguments as JSON body + let is_body_method = matches!(info.method.as_str(), "POST" | "PUT" | "PATCH"); + if is_body_method && !req.arguments.is_empty() { + request_builder = request_builder + .header("content-type", "application/json") + .json(&req.arguments); + } else if !is_body_method && !req.arguments.is_empty() { + // For GET/DELETE etc, we could turn remaining args into query params. + // For MVP we rely on path params; extra args are ignored for now. + } + + let resp = request_builder.send().await.map_err(|e| { + McpError::ToolExecution(format!("failed to proxy tool call to main API: {}", e)) + })?; + + let status = resp.status(); + let is_error = !status.is_success(); + + let content = if let Ok(text) = resp.text().await { + if text.trim().is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)) + } + } else { + serde_json::Value::Null + }; + + Ok(crate::types::ToolCallResponse { + content, + is_error, + meta: Some(serde_json::json!({ "proxied_status": status.as_u16() })), + }) + } +} + +/// Result of the `initialize` handshake. +#[derive(Debug, Clone)] +pub struct InitializeResult { + /// Server name (from config). + pub name: String, + /// Server version (from config). + pub version: String, + /// Optional description. + pub description: Option, + /// Advertised capabilities. + pub capabilities: Vec, +} + +impl McpServer { + /// Start serving the MCP protocol over HTTP on the given address. + /// + /// This starts a sidecar HTTP server (separate from your main RustAPI HTTP server) + /// that MCP clients (Claude, etc.) can connect to for tool discovery and invocation. + /// + /// Supports a minimal JSON-RPC over POST transport for: + /// - `initialize` + /// - `tools/list` + /// - `tools/call` (proxies to the main RustAPI server so full middleware / validation / error handling applies) + pub async fn serve(self, addr: &str) -> Result<()> { + self.serve_with_shutdown(addr, std::future::pending()).await + } + + /// Like `serve`, but with a shutdown signal (e.g. ctrl_c()). + pub async fn serve_with_shutdown(self, addr: &str, signal: F) -> Result<()> + where + F: Future + Send + 'static, + { + let addr: SocketAddr = addr.parse().map_err(|e| { + McpError::Transport(format!("invalid MCP address '{}': {}", addr, e)) + })?; + + let listener = TcpListener::bind(addr).await.map_err(|e| { + McpError::Transport(format!("failed to bind MCP listener on {}: {}", addr, e)) + })?; + + info!("🧠 MCP server listening on http://{}", addr); + + let this = Arc::new(self); + tokio::pin!(signal); + + loop { + tokio::select! { + biased; + + accept_result = listener.accept() => { + let (stream, remote) = match accept_result { + Ok(v) => v, + Err(e) => { + error!("MCP accept error: {}", e); + continue; + } + }; + + let io = TokioIo::new(stream); + let mcp = this.clone(); + + tokio::task::spawn(async move { + let service = hyper::service::service_fn(move |req| { + let mcp = mcp.clone(); + async move { handle_mcp_http_request(mcp, req).await } + }); + + if let Err(e) = http1::Builder::new() + .serve_connection(io, service) + .await + { + error!("MCP connection error from {}: {}", remote, e); + } + }); + } + + _ = &mut signal => { + info!("MCP server received shutdown signal"); + break; + } + } + } + + Ok(()) + } +} + +/// Handle a single HTTP request for the MCP protocol (minimal JSON-RPC over POST). +async fn handle_mcp_http_request( + mcp: Arc, + req: hyper::Request, +) -> std::result::Result>, Infallible> { + if req.method() != hyper::Method::POST { + let body = Bytes::from_static(b"{\"error\":\"MCP transport expects POST requests\"}"); + return Ok(Response::builder() + .status(405) + .header("content-type", "application/json") + .body(Full::new(body)) + .unwrap()); + } + + let body_bytes = match req.collect().await { + Ok(collected) => collected.to_bytes(), + Err(e) => { + let err_body = format!("{{\"error\":\"failed to read body: {}\"}}", e); + return Ok(Response::builder() + .status(400) + .header("content-type", "application/json") + .body(Full::new(Bytes::from(err_body))) + .unwrap()); + } + }; + + let json: serde_json::Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(_) => { + return Ok(jsonrpc_error_response( + serde_json::Value::Null, + -32700, + "parse error", + )); + } + }; + + 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(""); + + match method { + "initialize" => { + let init = mcp.initialize(); + let result = serde_json::json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": init.name, + "version": init.version + }, + "capabilities": { + "tools": {} + } + }); + Ok(jsonrpc_success_response(id, result)) + } + "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(); + + let result = serde_json::json!({ "tools": tool_defs }); + Ok(jsonrpc_success_response(id, result)) + } + Err(e) => Ok(jsonrpc_error_response(id, -32603, &format!("internal error: {}", e))), + } + } + "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 = crate::types::ToolCallRequest { name, arguments }; + + match mcp.call_tool(tool_req).await { + Ok(resp) => { + // Shape the response per MCP tools/call convention (protocol 2024-11-05): + // The result contains a "content" array of content blocks + "isError" flag. + // We serialize non-string content as pretty JSON text for broad client compatibility. + 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()) + }; + + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": text + }], + "isError": resp.is_error + }); + Ok(jsonrpc_success_response(id, result)) + } + Err(e) => { + // Surface execution errors as a successful JSON-RPC but with isError inside the result + // (this is the MCP convention so the client knows it was a tool-level failure). + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Tool execution error: {}", e) + }], + "isError": true + }); + Ok(jsonrpc_success_response(id, result)) + } + } + } + _ => Ok(jsonrpc_error_response(id, -32601, "method not found")), + } +} + +fn jsonrpc_success_response(id: serde_json::Value, result: serde_json::Value) -> Response> { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }); + Response::builder() + .header("content-type", "application/json") + .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) + .unwrap() +} + +fn jsonrpc_error_response( + id: serde_json::Value, + code: i32, + message: &str, +) -> Response> { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }); + Response::builder() + .header("content-type", "application/json") + .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) + .unwrap() +} + +/// Helpers duplicated from discovery for tool map building (small & self-contained). +fn path_matches_prefixes(path: &str, prefixes: &[String]) -> bool { + if prefixes.is_empty() { + return true; + } + prefixes.iter().any(|p| path.starts_with(p)) +} + +fn generate_tool_name(method: &str, path: &str, op: &rustapi_openapi::Operation) -> String { + if let Some(oid) = &op.operation_id { + return sanitize_name(oid); + } + let mut slug = path + .trim_start_matches('/') + .replace(['/', '{', '}', ':'], "_") + .replace(['-', '.', ' '], "_"); + while slug.contains("__") { + slug = slug.replace("__", "_"); + } + let slug = slug.trim_matches('_').to_string(); + let method_lower = method.to_lowercase(); + if slug.is_empty() { + method_lower + } else { + format!("{}_{}", method_lower, slug) + } +} + +fn sanitize_name(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect::() + .trim_matches('_') + .to_string() + .to_lowercase() +} + +/// Substitute {param} placeholders in the path template using values from the MCP arguments. +fn substitute_path_params(template: &str, args: &std::collections::HashMap) -> String { + let mut result = template.to_string(); + for (key, value) in args { + let placeholder = format!("{{{}}}", key); + if result.contains(&placeholder) { + let val_str = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string().trim_matches('"').to_string(), + }; + result = result.replace(&placeholder, &val_str); + } + } + result +} diff --git a/crates/rustapi-mcp/src/types.rs b/crates/rustapi-mcp/src/types.rs new file mode 100644 index 00000000..cc7d9e93 --- /dev/null +++ b/crates/rustapi-mcp/src/types.rs @@ -0,0 +1,90 @@ +//! Core MCP data types (tool definitions, requests, responses, capabilities). +//! +//! These types are intentionally high-level for the foundation phase. +//! They will be expanded with full JSON-RPC message shapes when we implement transports. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A capability that this MCP server advertises during `initialize`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum McpCapability { + /// The server can list and invoke tools. + Tools, + /// Future: resources, prompts, sampling, etc. + #[serde(other)] + Other, +} + +/// A tool description that will be sent to MCP clients in `tools/list`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpTool { + /// Stable name of the tool (usually derived from path + method or a slug). + pub name: String, + + /// Human readable description (comes from OpenAPI `summary` / `description` when available). + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// JSON Schema for the input parameters (reused from `rustapi-openapi` / `Schema` derive). + pub input_schema: serde_json::Value, + + /// Optional JSON Schema for the output (when we have good response schemas). + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + + /// Tags associated with this tool (used for filtering via `McpConfig`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// Request from an MCP client to call a tool. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallRequest { + /// Name of the tool to invoke. + pub name: String, + /// Arguments passed to the tool (will be turned into extractors later). + #[serde(default)] + pub arguments: HashMap, +} + +/// Successful result of a tool call. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResponse { + /// The actual content returned by the underlying handler (serialized appropriately). + pub content: serde_json::Value, + + /// Whether this result should be treated as an error by the agent (even if HTTP 2xx). + #[serde(default)] + pub is_error: bool, + + /// Optional metadata (token counts when using TOON, execution path, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ToolCallResponse { + /// Construct a successful tool response. + pub fn success(content: impl Serialize) -> Self { + Self { + content: serde_json::to_value(content).unwrap_or(serde_json::json!({})), + is_error: false, + meta: None, + } + } + + /// Construct an error tool response (from the agent's perspective). + pub fn error(message: impl Into) -> Self { + Self { + content: serde_json::json!({ "error": message.into() }), + is_error: true, + meta: None, + } + } +} + + diff --git a/crates/rustapi-mcp/tests/mcp_e2e.rs b/crates/rustapi-mcp/tests/mcp_e2e.rs new file mode 100644 index 00000000..3769ef31 --- /dev/null +++ b/crates/rustapi-mcp/tests/mcp_e2e.rs @@ -0,0 +1,360 @@ +//! End-to-end integration tests for Native MCP. +//! +//! These tests spin up a real RustAPI HTTP server + MCP sidecar on ephemeral ports, +//! exercise the JSON-RPC transport (initialize, tools/list, tools/call), and verify +//! that tool invocations are proxied through the normal pipeline (including tag-based +//! exposure control). + +use std::time::Duration; + +use rustapi_rs::prelude::*; +use rustapi_rs::protocol::mcp::{McpConfig, McpServer, run_rustapi_and_mcp_with_shutdown}; +use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; + +/// Simple response for a tagged tool. +#[derive(Serialize, Schema)] +struct Weather { + city: String, + temperature: i32, + unit: &'static str, +} + +/// Request body for a mutating tool. +#[derive(Serialize, Schema, Deserialize)] +struct ComputeRequest { + a: i32, + b: i32, +} + +#[derive(Serialize, Schema)] +struct ComputeResponse { + sum: i32, +} + +// ------------------ Tagged tools (will be exposed) ------------------ + +#[rustapi_rs::get("/weather/{city}")] +#[rustapi_rs::tag("agent")] +#[rustapi_rs::summary("Get current weather for a city")] +async fn get_weather(Path(city): Path) -> Json { + Json(Weather { + city, + temperature: 22, + unit: "C", + }) +} + +#[rustapi_rs::post("/compute")] +#[rustapi_rs::tag("agent")] +#[rustapi_rs::summary("Compute the sum of two numbers")] +async fn compute(Json(req): Json) -> Json { + Json(ComputeResponse { sum: req.a + req.b }) +} + +// ------------------ Untagged / internal (must NOT be exposed) ------------------ + +#[rustapi_rs::get("/admin/secret")] +async fn admin_secret() -> &'static str { + "top-secret" +} + +#[tokio::test] +async fn test_mcp_initialize_and_filtered_tools_list() { + let app = RustApi::auto(); + + let mcp = McpServer::from_rustapi( + &app, + McpConfig::new() + .name("test-mcp-server") + .version("1.0.0-test") + .allowed_tags(["agent"]), + ); + + // Ephemeral ports for both servers + 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 mcp_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mcp_addr = mcp_listener.local_addr().unwrap(); + drop(mcp_listener); + + let http_addr_str = format!("127.0.0.1:{}", http_addr.port()); + let mcp_addr_str = format!("127.0.0.1:{}", mcp_addr.port()); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server_handle = tokio::spawn(async move { + run_rustapi_and_mcp_with_shutdown( + app, + &http_addr_str, + mcp, + &mcp_addr_str, + async move { + let _ = shutdown_rx.await; + }, + ) + .await + }); + + // Give servers time to bind and start + tokio::time::sleep(Duration::from_millis(250)).await; + + let client = reqwest::Client::new(); + let mcp_url = format!("http://127.0.0.1:{}/", mcp_addr.port()); + + // --- initialize --- + let init_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }); + + let res = client + .post(&mcp_url) + .json(&init_body) + .send() + .await + .expect("initialize request failed"); + assert_eq!(res.status(), 200); + + let init_resp: serde_json::Value = res.json().await.unwrap(); + assert_eq!(init_resp["jsonrpc"], "2.0"); + assert_eq!(init_resp["id"], 1); + assert!(init_resp.get("result").is_some()); + let result = &init_resp["result"]; + assert_eq!(result["serverInfo"]["name"], "test-mcp-server"); + assert!(result["capabilities"]["tools"].is_object()); + + // --- tools/list (only "agent" tagged) --- + let list_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }); + + let res = client + .post(&mcp_url) + .json(&list_body) + .send() + .await + .expect("tools/list request failed"); + assert_eq!(res.status(), 200); + + let list_resp: serde_json::Value = res.json().await.unwrap(); + let tools = &list_resp["result"]["tools"]; + let tool_names: Vec = tools + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect(); + + // Should contain the two agent-tagged tools, but not the admin one + assert!(tool_names.iter().any(|n| n.contains("get_weather") || n.contains("weather"))); + assert!(tool_names.iter().any(|n| n.contains("compute"))); + assert!(!tool_names.iter().any(|n| n.contains("secret") || n.contains("admin"))); + + // Trigger shutdown + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(Duration::from_secs(3), server_handle).await; +} + +#[tokio::test] +async fn test_mcp_tool_call_get_with_path_param_and_post_body() { + let app = RustApi::auto(); + + let mcp = McpServer::from_rustapi( + &app, + McpConfig::new() + .name("e2e-mcp") + .version("0.0.0") + .allowed_tags(["agent"]), + ); + + 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 mcp_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mcp_addr = mcp_listener.local_addr().unwrap(); + drop(mcp_listener); + + let http_addr_str = format!("127.0.0.1:{}", http_addr.port()); + let mcp_addr_str = format!("127.0.0.1:{}", mcp_addr.port()); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server_handle = tokio::spawn(async move { + run_rustapi_and_mcp_with_shutdown( + app, + &http_addr_str, + mcp, + &mcp_addr_str, + async move { let _ = shutdown_rx.await; }, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(250)).await; + + let client = reqwest::Client::new(); + let mcp_url = format!("http://127.0.0.1:{}/", mcp_addr.port()); + + // First discover the exact tool names (generation depends on operation_id / slug rules) + let list_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "list-for-call", + "method": "tools/list" + }); + let list_res = client + .post(&mcp_url) + .json(&list_body) + .send() + .await + .expect("tools/list before calls failed"); + let list_body: serde_json::Value = list_res.json().await.unwrap(); + let tools = list_body["result"]["tools"].as_array().unwrap(); + + let weather_tool = tools + .iter() + .find(|t| t["name"].as_str().unwrap_or("").contains("weather")) + .expect("weather tool should be discoverable") + ["name"] + .as_str() + .unwrap() + .to_string(); + + let compute_tool = tools + .iter() + .find(|t| t["name"].as_str().unwrap_or("").contains("compute")) + .expect("compute tool should be discoverable") + ["name"] + .as_str() + .unwrap() + .to_string(); + + // --- Call GET tool with path argument (using discovered name) --- + let call_get = serde_json::json!({ + "jsonrpc": "2.0", + "id": "call-1", + "method": "tools/call", + "params": { + "name": weather_tool, + "arguments": { "city": "Istanbul" } + } + }); + + let res = client + .post(&mcp_url) + .json(&call_get) + .send() + .await + .expect("tools/call (GET) failed"); + assert_eq!(res.status(), 200); + + let body: serde_json::Value = res.json().await.unwrap(); + let result = &body["result"]; + assert_eq!(result["isError"], false, "GET tool call should succeed: {:?}", result); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("Istanbul") || text.contains("22"), "response text should contain city or temp: {}", text); + + // --- Call POST tool with body (using discovered name) --- + let call_post = serde_json::json!({ + "jsonrpc": "2.0", + "id": "call-2", + "method": "tools/call", + "params": { + "name": compute_tool, + "arguments": { "a": 40, "b": 2 } + } + }); + + let res = client + .post(&mcp_url) + .json(&call_post) + .send() + .await + .expect("tools/call (POST) failed"); + assert_eq!(res.status(), 200); + + let body: serde_json::Value = res.json().await.unwrap(); + let result = &body["result"]; + assert_eq!(result["isError"], false, "POST tool call should succeed: {:?}", result); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("42") || text.contains("sum"), "response should contain sum result: {}", text); + + // Shutdown + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(Duration::from_secs(3), server_handle).await; +} + +#[tokio::test] +async fn test_mcp_tool_not_found_for_untagged_route() { + let app = RustApi::auto(); + + let mcp = McpServer::from_rustapi( + &app, + McpConfig::new().allowed_tags(["agent"]), // only agent tools + ); + + 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 mcp_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mcp_addr = mcp_listener.local_addr().unwrap(); + drop(mcp_listener); + + let http_addr_str = format!("127.0.0.1:{}", http_addr.port()); + let mcp_addr_str = format!("127.0.0.1:{}", mcp_addr.port()); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server_handle = tokio::spawn(async move { + run_rustapi_and_mcp_with_shutdown( + app, + &http_addr_str, + mcp, + &mcp_addr_str, + async move { let _ = shutdown_rx.await; }, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(250)).await; + + let client = reqwest::Client::new(); + let mcp_url = format!("http://127.0.0.1:{}/", mcp_addr.port()); + + // Try to call the untagged admin tool by a plausible generated name + let bad_call = serde_json::json!({ + "jsonrpc": "2.0", + "id": 99, + "method": "tools/call", + "params": { + "name": "get_admin_secret", + "arguments": {} + } + }); + + let res = client.post(&mcp_url).json(&bad_call).send().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + + // Either we get a JSON-RPC error or a result with isError (our current impl uses result+isError for execution errors) + // The ToolNotFound path currently surfaces as a successful response with isError + error text. + if let Some(err) = body.get("error") { + // protocol level error is also acceptable + assert!(err["message"].as_str().unwrap_or("").contains("not found")); + } else { + let result = &body["result"]; + assert_eq!(result["isError"], true); + let text = result["content"][0]["text"].as_str().unwrap_or(""); + assert!(text.to_lowercase().contains("not found") || text.contains("ToolNotFound")); + } + + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(Duration::from_secs(3), server_handle).await; +} diff --git a/crates/rustapi-openapi/src/lib.rs b/crates/rustapi-openapi/src/lib.rs index 033c6894..04e7c529 100644 --- a/crates/rustapi-openapi/src/lib.rs +++ b/crates/rustapi-openapi/src/lib.rs @@ -81,7 +81,7 @@ pub use schemas::{ ValidationErrorSchema, }; pub use spec::{ - ApiInfo, MediaType, OpenApiSpec, Operation, OperationModifier, Parameter, PathItem, + ApiInfo, Components, MediaType, OpenApiSpec, Operation, OperationModifier, Parameter, PathItem, RequestBody, ResponseModifier, ResponseSpec, SchemaRef, }; diff --git a/crates/rustapi-rs/Cargo.toml b/crates/rustapi-rs/Cargo.toml index f965049b..02bcb333 100644 --- a/crates/rustapi-rs/Cargo.toml +++ b/crates/rustapi-rs/Cargo.toml @@ -20,6 +20,7 @@ rustapi-toon = { workspace = true, optional = true } rustapi-ws = { workspace = true, optional = true } rustapi-view = { workspace = true, optional = true } rustapi-grpc = { workspace = true, optional = true } +rustapi-mcp = { workspace = true, optional = true } rustapi-validate = { workspace = true } async-trait = { workspace = true } futures-util = { workspace = true } @@ -35,7 +36,7 @@ rustapi-openapi = { workspace = true, default-features = false } [dev-dependencies] rustapi-core = { workspace = true } rustapi-macros = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } doc-comment = "0.3" uuid = { workspace = true, features = ["serde", "v4"] } @@ -60,9 +61,10 @@ protocol-toon = ["dep:rustapi-toon"] protocol-ws = ["dep:rustapi-ws"] protocol-view = ["dep:rustapi-view"] protocol-grpc = ["dep:rustapi-grpc"] +protocol-mcp = ["dep:rustapi-mcp"] protocol-http3 = ["core-http3"] protocol-http3-dev = ["core-http3-dev"] -protocol-all = ["protocol-toon", "protocol-ws", "protocol-view", "protocol-grpc"] +protocol-all = ["protocol-toon", "protocol-ws", "protocol-view", "protocol-grpc", "protocol-mcp"] # Canonical extras features extras-jwt = ["dep:rustapi-extras", "rustapi-extras/jwt"] @@ -128,6 +130,7 @@ toon = ["protocol-toon"] ws = ["protocol-ws"] view = ["protocol-view"] grpc = ["protocol-grpc"] +mcp = ["protocol-mcp"] jwt = ["extras-jwt"] cors = ["extras-cors"] rate-limit = ["extras-rate-limit"] diff --git a/crates/rustapi-rs/README.md b/crates/rustapi-rs/README.md index 00aab5ec..85a39082 100644 --- a/crates/rustapi-rs/README.md +++ b/crates/rustapi-rs/README.md @@ -44,7 +44,44 @@ Feature taxonomy on the facade: ## 📦 Quick Start -Add `rustapi-rs` to your `Cargo.toml`. +**Önerilen kullanım** (en temiz ve kısa makro isimleri için): + +```toml +[dependencies] +api = { package = "rustapi-rs", version = "0.1.478" } +``` + +Sonra kodunda: + +```rust +use api::prelude::*; + +#[api::get("/hello")] +async fn hello() -> &'static str { + "Hello from RustAPI!" +} + +#[api::main] +async fn main() -> Result<(), Box> { + api::RustApi::auto().run("127.0.0.1:8080").await +} +``` + +Eğer istersen direkt uzun isimle de kullanabilirsin: + +```toml +[dependencies] +rustapi-rs = "0.1.478" +``` + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/hello")] +... +``` + +Add `rustapi-rs` to your `Cargo.toml` (kısa isim için alias önerilir): ```toml [dependencies] diff --git a/crates/rustapi-rs/examples/README.md b/crates/rustapi-rs/examples/README.md index 6b01b370..09e309e7 100644 --- a/crates/rustapi-rs/examples/README.md +++ b/crates/rustapi-rs/examples/README.md @@ -96,6 +96,23 @@ Then open: - `http://127.0.0.1:3000/slow` - `http://127.0.0.1:3000/flaky` +### `mcp_tools` + +Demonstrates running your normal HTTP API together with a Native MCP server. Selected routes (those tagged `"agent"`) are automatically exposed as discoverable tools for LLMs and agent clients (Claude, Cursor, etc.). Tool calls are proxied through the full RustAPI request pipeline. + +Run it with: + +```sh +cargo run -p rustapi-rs --example mcp_tools --features protocol-mcp +``` + +The example starts two listeners: + +- HTTP API on `http://127.0.0.1:8080` +- MCP endpoint on `http://127.0.0.1:9090` (point your MCP client here) + +You can also drive it manually with `curl` (see the comments at the top of the example file for ready-to-paste JSON-RPC commands). + ## Notes - Keep this file aligned with the actual `.rs` files in this directory. diff --git a/crates/rustapi-rs/examples/mcp_tools.rs b/crates/rustapi-rs/examples/mcp_tools.rs new file mode 100644 index 00000000..60ac8ef8 --- /dev/null +++ b/crates/rustapi-rs/examples/mcp_tools.rs @@ -0,0 +1,120 @@ +//! Demonstrates running a normal RustAPI HTTP server side-by-side with a +//! Native MCP server so that LLMs / agents (Claude, Cursor, etc.) can discover +//! and call your endpoints as tools. +//! +//! Only routes that carry the "agent" tag are exposed via MCP. +//! All tool invocations are proxied through the real HTTP pipeline +//! (middleware, validation, extractors, error handling, etc.). +//! +//! Run with: +//! cargo run -p rustapi-rs --example mcp_tools --features protocol-mcp +//! +//! Then: +//! - Normal HTTP API is on http://127.0.0.1:8080 +//! - MCP endpoint (for agents) is on http://127.0.0.1:9090 +//! +//! Try from another terminal: +//! curl -X POST http://127.0.0.1:9090 \ +//! -H 'content-type: application/json' \ +//! -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' +//! +//! curl -X POST http://127.0.0.1:9090 \ +//! -H 'content-type: application/json' \ +//! -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +//! +//! # Use the exact name returned by tools/list for the call +//! curl -X POST http://127.0.0.1:9090 \ +//! -H 'content-type: application/json' \ +//! -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_agent_weather_city","arguments":{"city":"Istanbul"}}}' + +use rustapi_rs::prelude::*; +use rustapi_rs::protocol::mcp::{McpConfig, McpServer, run_rustapi_and_mcp_with_shutdown}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Schema)] +struct Weather { + city: String, + temperature: i32, + unit: &'static str, +} + +#[derive(Deserialize, Serialize, Schema)] +struct SumRequest { + a: i32, + b: i32, +} + +#[derive(Serialize, Schema)] +struct SumResponse { + sum: i32, +} + +/// This route will be exposed as an MCP tool because of the "agent" tag. +#[rustapi_rs::get("/agent/weather/{city}")] +#[rustapi_rs::tag("agent")] +#[rustapi_rs::summary("Get weather information for a city")] +async fn get_weather(Path(city): Path) -> Json { + Json(Weather { + city, + temperature: 23, + unit: "C", + }) +} + +/// Another exposed tool (POST with JSON body). +#[rustapi_rs::post("/agent/sum")] +#[rustapi_rs::tag("agent")] +#[rustapi_rs::summary("Add two integers and return the result")] +async fn sum(Json(req): Json) -> Json { + Json(SumResponse { sum: req.a + req.b }) +} + +/// This route is deliberately NOT tagged — it will NOT appear in MCP discovery. +#[rustapi_rs::get("/admin/internal-config")] +async fn internal_only() -> &'static str { + "this-should-never-be-visible-to-agents" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let app = RustApi::auto(); + + // Only routes carrying at least one of the allowed tags become MCP tools. + // This is the primary safety mechanism. + let mcp = McpServer::from_rustapi( + &app, + McpConfig::new() + .name("rustapi-mcp-demo") + .version("0.1.0") + .description("Demo RustAPI instance exposing selected endpoints to AI agents via MCP") + .allowed_tags(["agent"]), + ); + + let http_addr = "0.0.0.0:8080"; + let mcp_addr = "0.0.0.0:9090"; + + println!("🚀 HTTP API listening on http://{http_addr}"); + println!("🧠 MCP server (for agents) http://{mcp_addr}"); + println!(); + println!("Useful MCP JSON-RPC calls (use tools/list to discover exact tool names):"); + println!(" curl -X POST http://{mcp_addr} -H 'content-type: application/json' \\"); + println!(" -d '{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}}'"); + println!(); + println!(" curl -X POST http://{mcp_addr} -H 'content-type: application/json' \\"); + println!(" -d '{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}}'"); + println!(); + println!("Press Ctrl+C to stop both servers cleanly."); + + run_rustapi_and_mcp_with_shutdown( + app, + http_addr, + mcp, + mcp_addr, + async { + let _ = tokio::signal::ctrl_c().await; + }, + ) + .await?; + + Ok(()) +} diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index a2340588..9a5c35e9 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -8,11 +8,22 @@ extern crate self as rustapi_rs; pub use rustapi_macros::*; /// Macro/runtime internals. Not part of the public compatibility contract. +/// +/// This module is only for use by `rustapi-macros` and internal implementation. +/// It re-exports `linkme` (used for automatic route & schema registration via +/// distributed slices) and the raw slices. +/// +/// External users should never name anything inside `__private`. #[doc(hidden)] pub mod __private { pub use async_trait; pub use rustapi_core as core; - pub use rustapi_core::__private::{linkme, AUTO_ROUTES, AUTO_SCHEMAS}; + + /// Re-export of `linkme` for our proc-macro generated attributes. + /// See `rustapi_core::__private::linkme` for more details. + pub use rustapi_core::__private::linkme; + + pub use rustapi_core::__private::{AUTO_ROUTES, AUTO_SCHEMAS}; pub use rustapi_openapi as openapi; pub use rustapi_validate as validate; pub use serde_json; @@ -20,7 +31,7 @@ pub mod __private { /// Stable core surface exposed by the facade. pub mod core { - pub use rustapi_core::collect_auto_routes; + pub use rustapi_core::{auto_route_count, collect_auto_routes}; pub use rustapi_core::validation::Validatable; pub use rustapi_core::EventBus; pub use rustapi_core::{ @@ -78,6 +89,11 @@ pub mod protocol { pub use rustapi_grpc::*; } + #[cfg(any(feature = "protocol-mcp", feature = "mcp"))] + pub mod mcp { + pub use rustapi_mcp::*; + } + #[cfg(any(feature = "core-http3", feature = "protocol-http3", feature = "http3"))] pub mod http3 { pub use rustapi_core::{Http3Config, Http3Server}; @@ -345,16 +361,17 @@ pub mod prelude { pub use crate::core::EventBus; pub use crate::core::Validatable; pub use crate::core::{ - delete, delete_route, get, get_route, patch, patch_route, post, post_route, put, put_route, - route, serve_dir, sse_from_iter, sse_response, ApiError, AsyncValidatedJson, Body, - BodyLimitLayer, ClientIp, Created, 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, + auto_route_count, collect_auto_routes, delete, delete_route, get, get_route, patch, + patch_route, post, post_route, put, put_route, route, serve_dir, sse_from_iter, + sse_response, ApiError, AsyncValidatedJson, Body, BodyLimitLayer, ClientIp, Created, + 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, }; #[cfg(any(feature = "core-compression", feature = "compression"))] @@ -431,6 +448,11 @@ pub mod prelude { pub use crate::protocol::grpc::{ run_concurrently, run_rustapi_and_grpc, run_rustapi_and_grpc_with_shutdown, }; + + #[cfg(any(feature = "protocol-mcp", feature = "mcp"))] + pub use crate::protocol::mcp::{ + run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, + }; } #[cfg(test)] diff --git a/crates/rustapi-rs/tests/auto_route.rs b/crates/rustapi-rs/tests/auto_route.rs index 7cea543d..54a7287b 100644 --- a/crates/rustapi-rs/tests/auto_route.rs +++ b/crates/rustapi-rs/tests/auto_route.rs @@ -17,10 +17,15 @@ async fn auto_handler_rs_post() -> &'static str { #[test] fn test_auto_registration_rs() { - // Collect routes + // Note: This test can be flaky in some environments because linkme slices + // are populated at link time. The test binary may not include all annotated + // handlers the same way the final binary does. + // + // In real projects it is common to either: + // - Rely on `RustApi::auto()` (which also uses the same slice) + // - Or fall back to manual `.route()` registration inside tests. let routes = collect_auto_routes(); - // Filter to find our specific routes let found_auto = routes .iter() .any(|r| r.path() == "/test-auto-rs" && r.method() == "GET"); @@ -28,10 +33,10 @@ fn test_auto_registration_rs() { .iter() .any(|r| r.path() == "/test-auto-rs-post" && r.method() == "POST"); - assert!(found_auto, "Should find /test-auto-rs GET route"); - assert!(found_auto_post, "Should find /test-auto-rs-post POST route"); + assert!(found_auto, "Should find /test-auto-rs GET route (linkme)"); + assert!(found_auto_post, "Should find /test-auto-rs-post POST route (linkme)"); - println!("Found {} routes", routes.len()); + println!("Found {} routes via auto collection in this test binary", routes.len()); } #[get("/same-path")] @@ -92,6 +97,8 @@ async fn query_handler(Query(p): Query) -> &'static str { #[test] fn test_auto_groups_methods_by_path() { + // This test exercises the full `RustApi::auto()` path which internally + // calls collect_auto_routes + grouping. Same linkme caveats apply. let app = RustApi::auto(); let router = app.into_router(); diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 030c40c5..a044d534 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -25,6 +25,7 @@ - [Testing Utilities](crates/rustapi_testing.md) - [rustapi-ws: The Live Wire](crates/rustapi_ws.md) - [rustapi-grpc: The Bridge](crates/rustapi_grpc.md) + - [rustapi-mcp: The Agent Bridge](crates/rustapi_mcp.md) - [cargo-rustapi: The Architect](crates/cargo_rustapi.md) - [Part III.5: Reference](reference/README.md) @@ -52,6 +53,7 @@ - [Real-time Chat](recipes/websockets.md) - [Server-Side Rendering (SSR)](recipes/server_side_rendering.md) - [AI Integration (TOON)](recipes/ai_integration.md) + - [MCP Integration (Agent Tools)](recipes/mcp_integration.md) - [Production Tuning](recipes/high_performance.md) - [Response Compression](recipes/compression.md) - [Resilience Patterns](recipes/resilience.md) @@ -64,6 +66,7 @@ - [Deployment](recipes/deployment.md) - [HTTP/3 (QUIC)](recipes/http3_quic.md) - [gRPC Integration](recipes/grpc_integration.md) + - [MCP Integration (Agent Tools)](recipes/mcp_integration.md) - [Automatic Status Page](recipes/status_page.md) - [Troubleshooting: Common Gotchas](troubleshooting.md) diff --git a/docs/cookbook/src/recipes/README.md b/docs/cookbook/src/recipes/README.md index 5314cc86..fe6c6ed2 100644 --- a/docs/cookbook/src/recipes/README.md +++ b/docs/cookbook/src/recipes/README.md @@ -31,6 +31,7 @@ Each recipe follows a simple structure: - [Real-time Chat](websockets.md) - [Server-Side Rendering (SSR)](server_side_rendering.md) - [AI Integration (TOON)](ai_integration.md) +- [MCP Integration (Agent Tools)](mcp_integration.md) - [Production Tuning](high_performance.md) - [Response Compression](compression.md) - [Resilience Patterns](resilience.md) @@ -41,4 +42,5 @@ Each recipe follows a simple structure: - [Deployment](deployment.md) - [HTTP/3 (QUIC)](http3_quic.md) - [gRPC Integration](grpc_integration.md) +- [MCP Integration (Agent Tools)](mcp_integration.md) - [Automatic Status Page](status_page.md) diff --git a/docs/cookbook/src/recipes/ai_integration.md b/docs/cookbook/src/recipes/ai_integration.md index 68a71962..345495d5 100644 --- a/docs/cookbook/src/recipes/ai_integration.md +++ b/docs/cookbook/src/recipes/ai_integration.md @@ -92,7 +92,7 @@ curl -H "Accept: application/toon" http://localhost:3000/users ## Providing Context to AI -When building an MCP (Model Context Protocol) server or simply feeding data to an LLM, use the TOON format to maximize the context window. +When building an MCP (Model Context Protocol) server or simply feeding data to an LLM, use the TOON format to maximize the context window. See the [MCP Integration (Agent Tools)](mcp_integration.md) recipe for exposing your endpoints as callable tools for Claude, Cursor and other agents. ```rust,ignore // Example: Generating a prompt with TOON data