diff --git a/README.md b/README.md index 0c744cfe..6af65c2b 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Current benchmark methodology and canonical published performance claims live in ```toml [dependencies] -api = { package = "rustapi-rs", version = "0.1.478" } +api = { package = "rustapi-rs", version = "0.1.507" } ``` ```rust @@ -221,7 +221,7 @@ async fn main() -> std::result::Result<(), Box/openapi.json /// and use the base as the proxy target. + /// + /// If the server is not running (or doesn't serve /openapi.json yet), + /// prefer running `cargo rustapi mcp generate` with no flags inside your + /// RustAPI project — it will auto-generate the spec directly from source. #[arg(long, value_name = "URL", conflicts_with_all = ["spec", "url"])] pub api: Option, @@ -70,9 +74,16 @@ pub async fn mcp_generate(args: McpGenerateArgs) -> Result<()> { #[cfg(feature = "mcp")] { println!("🧠 RustAPI MCP generator"); - println!(" Loading OpenAPI spec..."); let spec_input = resolve_spec_source(&args)?; + let spec_input = if spec_input == "__AUTO_GENERATE__" { + println!(" No --spec/--url/--api given. Auto-generating OpenAPI spec from current project..."); + auto_generate_and_get_spec_path().await? + } else { + println!(" Loading OpenAPI spec..."); + spec_input + }; + let openapi: OpenApiSpec = load_openapi_spec(&spec_input) .await .with_context(|| format!("Failed to load OpenAPI spec from {}", spec_input))?; @@ -276,7 +287,11 @@ fn resolve_spec_source(args: &McpGenerateArgs) -> Result { let base = a.trim_end_matches('/'); return Ok(format!("{}/openapi.json", base)); } - anyhow::bail!("One of --spec , --url , or --api is required") + // No source given → auto-generate from current RustAPI project + // This allows `cargo rustapi mcp generate` to work without a running server + // or pre-existing openapi.json file. + // We return a special marker; the caller will handle auto generation. + Ok("__AUTO_GENERATE__".to_string()) } fn resolve_target(args: &McpGenerateArgs) -> Result { @@ -286,7 +301,9 @@ fn resolve_target(args: &McpGenerateArgs) -> Result { if let Some(a) = &args.api { return Ok(a.clone()); } - anyhow::bail!("--target is required (or use --api which doubles as target)") + // Auto mode default: assume the user's API runs on the common dev port + println!(" No --target provided. Defaulting target to http://localhost:8080 (you can override with --target)"); + Ok("http://localhost:8080".to_string()) } async fn load_openapi_spec(source: &str) -> Result { @@ -294,7 +311,15 @@ async fn load_openapi_spec(source: &str) -> Result { // remote-spec is pulled in by the mcp feature reqwest::get(source) .await - .context("Failed to fetch spec over HTTP")? + .with_context(|| format!( + "Failed to fetch OpenAPI spec from {}\n\ + \n\ + Tip: If this is your own RustAPI project, try running without --api:\n\ + \n cargo rustapi mcp generate --stdio\n\ + \n\ + This will auto-generate the spec directly from your code (no running server needed).", + source + ))? .text() .await .context("Failed to read response body")? @@ -333,6 +358,70 @@ fn sanitize_name(s: &str) -> String { .to_lowercase() } +/// When user runs `cargo rustapi mcp generate` without --spec/--url/--api, +/// we auto-extract the OpenAPI by running the project with a special env var +/// that makes RustApi print the spec and exit (before binding any port). +async fn auto_generate_and_get_spec_path() -> Result { + println!(" Spawning `cargo run` with RUSTAPI_DUMP_OPENAPI=1 to extract spec (no server binding)..."); + + let output = std::process::Command::new("cargo") + .arg("run") + .arg("--quiet") + .env("RUSTAPI_DUMP_OPENAPI", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .output() + .context( + "Failed to execute `cargo run` for spec dump. Are you inside a RustAPI project?", + )?; + + if !output.status.success() { + anyhow::bail!( + "Failed to build/run the project to extract OpenAPI.\n\ + Try running `cargo run` manually first to ensure it compiles, then retry `cargo rustapi mcp generate`." + ); + } + + let stdout = + String::from_utf8(output.stdout).context("Captured OpenAPI output was not valid UTF-8")?; + + // The dump prints the JSON (possibly after some startup prints from the app). + // Find the last occurrence of a top-level OpenAPI object for robustness. + let json_str = if let Some(idx) = stdout.rfind(r#""openapi": "3."#) { + // Go back to the opening { of that object + let start = stdout[..idx].rfind('{').unwrap_or(0); + let candidate = &stdout[start..]; + // cut at the last } + if let Some(end) = candidate.rfind('}') { + candidate[..=end].trim().to_string() + } else { + candidate.trim().to_string() + } + } else if let Some(idx) = stdout.find('{') { + let candidate = &stdout[idx..]; + candidate.trim().to_string() + } else { + stdout.trim().to_string() + }; + + if json_str.is_empty() || !json_str.starts_with('{') { + anyhow::bail!( + "Could not extract a valid OpenAPI JSON from the project dump.\n\ + Make sure your main uses RustApi::auto() or similar." + ); + } + + // Write to a temp file so load_openapi_spec can handle it uniformly + let temp_path = + std::env::temp_dir().join(format!("rustapi-auto-spec-{}.json", std::process::id())); + tokio::fs::write(&temp_path, &json_str) + .await + .context("Failed to write temp OpenAPI spec")?; + + println!(" ✓ Auto-generated spec written to temporary file"); + Ok(temp_path.to_string_lossy().to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index 450f22c9..6523cb21 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -547,6 +547,24 @@ impl RustApi { &self.openapi_spec } + /// If RUSTAPI_DUMP_OPENAPI=1 (or true), print the generated OpenAPI spec as JSON + /// to stdout and exit immediately. Used by `cargo rustapi mcp generate` to + /// extract the spec without needing a running HTTP server. + fn maybe_dump_openapi(&self) { + if let Ok(val) = std::env::var("RUSTAPI_DUMP_OPENAPI") { + if matches!(val.as_str(), "1" | "true" | "yes") { + let json = self.openapi_spec.to_json(); + // Print clean JSON only + if let Ok(pretty) = serde_json::to_string_pretty(&json) { + println!("{}", pretty); + } else { + println!("{}", json); + } + std::process::exit(0); + } + } + } + fn mount_auto_routes_grouped(mut self) -> Self { let routes = crate::auto_route::collect_auto_routes(); @@ -1523,6 +1541,8 @@ impl RustApi { /// .await /// ``` pub async fn run(mut self, addr: &str) -> Result<(), Box> { + self.maybe_dump_openapi(); + // Hot-reload mode banner self.print_hot_reload_banner(addr); @@ -1560,6 +1580,8 @@ impl RustApi { where F: std::future::Future + Send + 'static, { + self.maybe_dump_openapi(); + // Hot-reload mode banner self.print_hot_reload_banner(addr.as_ref()); diff --git a/crates/rustapi-core/src/handler.rs b/crates/rustapi-core/src/handler.rs index e7078617..97fc568f 100644 --- a/crates/rustapi-core/src/handler.rs +++ b/crates/rustapi-core/src/handler.rs @@ -435,6 +435,13 @@ impl Route { self } + /// Attach MCP metadata to this route (becomes `x-mcp` in OpenAPI). + /// This enables rich scoping like `skip`, `readonly`, `write`, `require = "confirm"`. + pub fn mcp(mut self, meta: rustapi_openapi::McpOperation) -> Self { + self.operation = self.operation.mcp(meta); + self + } + /// Get the route path pub fn path(&self) -> &str { self.path diff --git a/crates/rustapi-macros/src/lib.rs b/crates/rustapi-macros/src/lib.rs index a2b161ec..2a46e0f4 100644 --- a/crates/rustapi-macros/src/lib.rs +++ b/crates/rustapi-macros/src/lib.rs @@ -651,6 +651,55 @@ fn generate_route_handler(method: &str, attr: TokenStream, item: TokenStream) -> let val = lit.value(); chained_calls = quote! { #chained_calls .description(#val) }; } + } else if ident_str == "mcp" { + // Rich #[mcp(...)] support. + // We build a rustapi_openapi::McpOperation struct and call .mcp(meta) + if let Ok(mcp_args) = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) { + let mut skip = quote! { None }; + let mut readonly = quote! { None }; + let mut write = quote! { None }; + let mut require = quote! { None }; + + for meta in mcp_args { + match &meta { + Meta::Path(path) => { + if let Some(ident) = path.get_ident() { + let s = ident.to_string().to_lowercase(); + if s == "skip" { + skip = quote! { Some(true) }; + } else if s == "readonly" { + readonly = quote! { Some(true) }; + } else if s == "write" { + write = quote! { Some(true) }; + } + } + } + Meta::NameValue(nv) => { + let key = nv.path.get_ident().map(|i| i.to_string().to_lowercase()); + if key.as_deref() == Some("require") { + if let Expr::Lit(lit) = &nv.value { + if let Lit::Str(s) = &lit.lit { + let val = s.value(); + require = quote! { Some(#val.to_string()) }; + } + } + } + } + _ => {} + } + } + + chained_calls = quote! { + #chained_calls .mcp( #rustapi_path::__private::openapi::McpOperation { + skip: #skip, + readonly: #readonly, + write: #write, + require: #require, + }) + }; + } } else if ident_str == "param" { // Parse #[param(name, schema = "type")] or #[param(name = "type")] if let Ok(param_args) = attr.parse_args_with( @@ -962,6 +1011,32 @@ pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// MCP metadata attribute for controlling how an endpoint is exposed as an MCP tool. +/// +/// Supports: +/// - `#[mcp(skip)]` — never expose this route as a tool +/// - `#[mcp(readonly)]` — treat as read-only even if it's a POST etc. +/// - `#[mcp(write)]` — mark as a write operation +/// - `#[mcp(require = "confirm")]` — agent should ask for confirmation +/// +/// # Example +/// +/// ```rust,ignore +/// #[rustapi::get("/admin/secrets")] +/// #[rustapi::mcp(skip)] +/// async fn admin_secrets() -> &'static str { "secret" } +/// +/// #[rustapi::post("/orders")] +/// #[rustapi::mcp(write, require = "confirm")] +/// async fn create_order(...) { ... } +/// ``` +#[proc_macro_attribute] +pub fn mcp(_attr: TokenStream, item: TokenStream) -> TokenStream { + // This is a passthrough. The actual semantics are handled by the + // route macros (get/post/...) which inspect #[mcp(...)] attrs on the fn. + item +} + /// Path parameter schema macro for OpenAPI documentation /// /// Use this to specify the OpenAPI schema type for a path parameter when diff --git a/crates/rustapi-mcp/src/config.rs b/crates/rustapi-mcp/src/config.rs index 12e70cf1..bb10e0bc 100644 --- a/crates/rustapi-mcp/src/config.rs +++ b/crates/rustapi-mcp/src/config.rs @@ -46,6 +46,29 @@ pub struct McpConfig { /// Proxy (default) always goes over HTTP (correct and works for external targets). /// InProcess / Auto are for when an in-process RustApi instance is available. pub invocation_mode: InvocationMode, + + /// Permission policy for which operations are exposed as MCP tools. + /// + /// Framework-native guardrail. By default we are conservative for agent use: + /// ReadOnly (only safe methods like GET are exposed unless you opt into writes). + /// + /// This addresses the blast radius concern when agents can call destructive endpoints. + pub tool_policy: ToolPolicy, +} + +/// Controls which operations (by HTTP semantics) are turned into MCP tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToolPolicy { + /// Expose everything (subject to `allowed_tags` / prefixes). + /// Use with care — agents can trigger writes/deletes. + All, + + /// Only expose read-only operations (GET, HEAD, OPTIONS). + /// Strongly recommended default when giving tools to AI agents. + #[default] + ReadOnly, + // Future: fully custom allow-list + confirmation requirements. + // Custom { ... }, } /// Controls whether tool invocation goes through the normal HTTP path or @@ -74,6 +97,7 @@ impl Default for McpConfig { expose_detailed_errors: false, max_tools: 256, invocation_mode: InvocationMode::Proxy, + tool_policy: ToolPolicy::ReadOnly, // Safe default for agent-facing use } } } @@ -149,4 +173,16 @@ impl McpConfig { self.invocation_mode = mode; self } + + /// Set the permission policy for exposing tools. + /// + /// `ReadOnly` is the safe default when agents will call your tools. + /// Only GET/HEAD/OPTIONS operations are turned into tools. + /// + /// Use `All` if you explicitly want agents to perform writes (and you have + /// strong `allowed_tags` + confirmation flows). + pub fn tool_policy(mut self, policy: ToolPolicy) -> Self { + self.tool_policy = policy; + self + } } diff --git a/crates/rustapi-mcp/src/discovery.rs b/crates/rustapi-mcp/src/discovery.rs index e8fd071d..7a169861 100644 --- a/crates/rustapi-mcp/src/discovery.rs +++ b/crates/rustapi-mcp/src/discovery.rs @@ -4,7 +4,7 @@ //! HTTP operations into MCP `McpTool` definitions, applying exposure //! filters from `McpConfig`. -use crate::config::McpConfig; +use crate::config::{McpConfig, ToolPolicy}; use crate::types::McpTool; use rustapi_openapi::{Components, OpenApiSpec, Operation, Parameter, RequestBody, SchemaRef}; use std::collections::BTreeMap; @@ -73,6 +73,29 @@ fn path_matches_prefixes(path: &str, prefixes: &[String]) -> bool { prefixes.iter().any(|p| path.starts_with(p)) } +/// Classify an HTTP method as read or write for permission scoping. +fn is_read_method(method: &str) -> bool { + matches!(method.to_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS") +} + +/// Returns whether this operation should be exposed given the current policy. +fn operation_allowed_by_policy(method: &str, _op: &Operation, policy: &ToolPolicy) -> bool { + match policy { + ToolPolicy::All => true, + ToolPolicy::ReadOnly => is_read_method(method), + // Custom can be added later + } +} + +/// Support for route-level skip via special tags (hot-fix until full #[mcp(skip)] proc-macro). +/// Any tag exactly "mcp-skip" or containing ":skip" will drop the operation. +fn is_skipped_by_tag(op: &Operation) -> bool { + op.tags.iter().any(|t| { + let t = t.to_lowercase(); + t == "mcp-skip" || t.contains(":skip") || t == "mcp:skip" + }) +} + fn operation_to_tool( method: &str, path: &str, @@ -80,7 +103,24 @@ fn operation_to_tool( components: Option<&Components>, config: &McpConfig, ) -> Option { - // Tag filtering (if allowed_tags configured, operation must have at least one matching tag) + // Rich x-mcp struct takes precedence + if let Some(mcp_meta) = &op.x_mcp { + if mcp_meta.skip == Some(true) { + return None; + } + } + + // Legacy tag skip + if is_skipped_by_tag(op) { + return None; + } + + // Policy gating + if !operation_allowed_by_policy(method, op, &config.tool_policy) { + return None; + } + + // Tag filtering if !config.allowed_tags.is_empty() { let has_match = op.tags.iter().any(|t| config.allowed_tags.contains(t)); if !has_match { @@ -93,12 +133,54 @@ fn operation_to_tool( let input_schema = build_input_schema(op, components); + // Derive permission + confirmation from x-mcp (rich) or tags / method + let (permission, requires_confirmation) = if let Some(mcp_meta) = &op.x_mcp { + let p = if mcp_meta.readonly == Some(true) { + "read".to_string() + } else if mcp_meta.write == Some(true) || !is_read_method(method) { + "write".to_string() + } else { + if is_read_method(method) { + "read" + } else { + "write" + } + .to_string() + }; + let needs_confirm = + mcp_meta.require.is_some() || (p == "write" && mcp_meta.readonly != Some(true)); + (p, needs_confirm) + } else { + let has_write = op.tags.iter().any(|t| t.eq_ignore_ascii_case("mcp-write")); + let has_ro = op + .tags + .iter() + .any(|t| t.eq_ignore_ascii_case("mcp-readonly")); + let req = op + .tags + .iter() + .any(|t| t.to_lowercase().starts_with("mcp-require")); + + let p = if has_ro { + "read" + } else if has_write || !is_read_method(method) { + "write" + } else { + "read" + } + .to_string(); + let c = req || (!has_ro && !is_read_method(method)); + (p, c) + }; + Some(McpTool { name, description, input_schema, - output_schema: None, // Future: extract from success responses + output_schema: None, tags: op.tags.clone(), + permission: Some(permission), + requires_confirmation: Some(requires_confirmation), }) } @@ -283,7 +365,7 @@ mod tests { #[test] fn extracts_tools_with_operation_id_as_name() { let spec = make_minimal_spec(); - let config = McpConfig::new(); + let config = McpConfig::new().tool_policy(ToolPolicy::All); // test covers write ops too let tools = extract_tools_from_spec(&spec, &config); assert!(!tools.is_empty()); diff --git a/crates/rustapi-mcp/src/lib.rs b/crates/rustapi-mcp/src/lib.rs index 7597a231..61dcddb5 100644 --- a/crates/rustapi-mcp/src/lib.rs +++ b/crates/rustapi-mcp/src/lib.rs @@ -11,9 +11,11 @@ //! - **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). +//! (tags, paths, or manual registration). Destructive operations are hidden by default via +//! `ToolPolicy::ReadOnly`. //! - **Respect the pipeline**: Every tool invocation goes through your normal middleware, //! interceptors, extractors, validation, and error handling. No secret bypass paths. +//! - **Permission metadata**: Tools declare "read" vs "write" and whether confirmation is needed. //! //! ## Current Status //! @@ -21,6 +23,7 @@ //! //! - 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. +//! - Framework-native permission scoping (`ToolPolicy::ReadOnly` default, `#[mcp(skip)]`, `#[mcp(write, require="confirm")]`). //! - 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). @@ -65,7 +68,7 @@ pub mod server; pub mod types; // Re-export the most important items at the crate root for convenience. -pub use config::{InvocationMode, McpConfig}; +pub use config::{InvocationMode, McpConfig, ToolPolicy}; pub use error::{McpError, Result}; pub use runner::{ run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, BoxError, @@ -78,7 +81,7 @@ pub use rustapi_openapi::OpenApiSpec; /// Prelude for common MCP types. pub mod prelude { - pub use crate::config::{InvocationMode, McpConfig}; + pub use crate::config::{InvocationMode, McpConfig, ToolPolicy}; pub use crate::error::{McpError, Result}; pub use crate::runner::{ run_concurrently, run_rustapi_and_mcp, run_rustapi_and_mcp_with_shutdown, diff --git a/crates/rustapi-mcp/src/server.rs b/crates/rustapi-mcp/src/server.rs index 9fa3d1c7..d1e33317 100644 --- a/crates/rustapi-mcp/src/server.rs +++ b/crates/rustapi-mcp/src/server.rs @@ -527,11 +527,24 @@ async fn handle_mcp_http_request( let tool_defs: Vec<_> = tools .into_iter() .map(|t| { - serde_json::json!({ + let mut def = serde_json::json!({ "name": t.name, "description": t.description, "inputSchema": t.input_schema - }) + }); + + // Permission scoping metadata (framework-native) + if let Some(perm) = &t.permission { + def["permission"] = serde_json::Value::String(perm.clone()); + } + if let Some(confirm) = t.requires_confirmation { + if confirm { + def["requiresConfirmation"] = serde_json::Value::Bool(true); + } + } + // Future: "confirmationMessage" + + def }) .collect(); diff --git a/crates/rustapi-mcp/src/types.rs b/crates/rustapi-mcp/src/types.rs index cb4707ae..3bb369a0 100644 --- a/crates/rustapi-mcp/src/types.rs +++ b/crates/rustapi-mcp/src/types.rs @@ -38,6 +38,15 @@ pub struct McpTool { /// Tags associated with this tool (used for filtering via `McpConfig`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + + /// Framework-level permission classification ("read" | "write"). + /// This is the key part of native scoping — agents can see the blast radius. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission: Option, + + /// Whether this tool should trigger a confirmation prompt on the agent side. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_confirmation: Option, } /// Request from an MCP client to call a tool. diff --git a/crates/rustapi-mcp/tests/mcp_e2e.rs b/crates/rustapi-mcp/tests/mcp_e2e.rs index 41ba98d6..7def7c6f 100644 --- a/crates/rustapi-mcp/tests/mcp_e2e.rs +++ b/crates/rustapi-mcp/tests/mcp_e2e.rs @@ -70,7 +70,8 @@ async fn test_mcp_initialize_and_filtered_tools_list() { McpConfig::new() .name("test-mcp-server") .version("1.0.0-test") - .allowed_tags(["agent"]), + .allowed_tags(["agent"]) + .tool_policy(rustapi_mcp::ToolPolicy::All), // test needs write tools ); // Ephemeral ports for both servers @@ -313,7 +314,12 @@ async fn bench_proxy_vs_inprocess() { // --- Discover tool name (same for both) --- let app_disc = RustApi::auto(); - let mcp_disc = McpServer::from_rustapi(&app_disc, McpConfig::new().allowed_tags(["agent"])); + let mcp_disc = McpServer::from_rustapi( + &app_disc, + McpConfig::new() + .allowed_tags(["agent"]) + .tool_policy(rustapi_mcp::ToolPolicy::All), + ); let tools = mcp_disc.list_tools().await.unwrap(); let tool_name = tools .iter() @@ -332,6 +338,7 @@ async fn bench_proxy_vs_inprocess() { &app_in, McpConfig::new() .allowed_tags(["agent"]) + .tool_policy(rustapi_mcp::ToolPolicy::All) .invocation_mode(InvocationMode::InProcess), ); @@ -355,6 +362,7 @@ async fn bench_proxy_vs_inprocess() { &app_p, McpConfig::new() .allowed_tags(["agent"]) + .tool_policy(rustapi_mcp::ToolPolicy::All) .invocation_mode(InvocationMode::Proxy), ); @@ -419,7 +427,9 @@ async fn test_mcp_tool_not_found_for_untagged_route() { let mcp = McpServer::from_rustapi( &app, - McpConfig::new().allowed_tags(["agent"]), // only agent tools + McpConfig::new() + .allowed_tags(["agent"]) + .tool_policy(rustapi_mcp::ToolPolicy::ReadOnly), // only agent tools ); let http_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/rustapi-openapi/src/lib.rs b/crates/rustapi-openapi/src/lib.rs index 04e7c529..7edc13b5 100644 --- a/crates/rustapi-openapi/src/lib.rs +++ b/crates/rustapi-openapi/src/lib.rs @@ -81,8 +81,8 @@ pub use schemas::{ ValidationErrorSchema, }; pub use spec::{ - ApiInfo, Components, MediaType, OpenApiSpec, Operation, OperationModifier, Parameter, PathItem, - RequestBody, ResponseModifier, ResponseSpec, SchemaRef, + ApiInfo, Components, McpOperation, MediaType, OpenApiSpec, Operation, OperationModifier, + Parameter, PathItem, RequestBody, ResponseModifier, ResponseSpec, SchemaRef, }; // Re-export Schema derive macro diff --git a/crates/rustapi-openapi/src/spec.rs b/crates/rustapi-openapi/src/spec.rs index 9abd78cd..e4f8dcff 100644 --- a/crates/rustapi-openapi/src/spec.rs +++ b/crates/rustapi-openapi/src/spec.rs @@ -476,6 +476,10 @@ pub struct Operation { pub security: Vec>>, #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, + + /// MCP tool metadata (serialized as OpenAPI extension `x-mcp`). + #[serde(default, skip_serializing_if = "Option::is_none", rename = "x-mcp")] + pub x_mcp: Option, } impl Operation { @@ -495,6 +499,35 @@ impl Operation { self.description = Some(d.into()); self } + + /// Attach MCP-specific metadata to this operation (serialized as `x-mcp` extension). + pub fn mcp(mut self, meta: McpOperation) -> Self { + self.x_mcp = Some(meta); + self + } +} + +/// MCP-specific metadata for an operation. +/// Serialized under the `x-mcp` extension in OpenAPI so that MCP tools can +/// carry permission and confirmation hints. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpOperation { + /// Skip exposing this operation as an MCP tool entirely. + #[serde(skip_serializing_if = "Option::is_none")] + pub skip: Option, + + /// Force this operation to be treated as read-only (even if the HTTP method is POST etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub readonly: Option, + + /// Explicitly mark as a write operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub write: Option, + + /// Agent should ask for confirmation before calling. The value can be "confirm" + /// or a human message. + #[serde(skip_serializing_if = "Option::is_none")] + pub require: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/rustapi-rs/examples/README.md b/crates/rustapi-rs/examples/README.md index 7eedf967..6bd15f61 100644 --- a/crates/rustapi-rs/examples/README.md +++ b/crates/rustapi-rs/examples/README.md @@ -115,6 +115,8 @@ You can also drive it manually with `curl` (see the comments at the top of the e See also the dedicated cookbook recipes for MCP in-process, the `cargo rustapi mcp generate` CLI (for any OpenAPI), and stdio transport. +For a more complete, standalone MCP example (with full project structure, ready-to-use Cargo project), see the [rustapi-rs-examples](https://github.com/Tuntii/rustapi-rs-examples) repository (05-mcp-server example). + ## 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 index b1daafff..c8f94c56 100644 --- a/crates/rustapi-rs/examples/mcp_tools.rs +++ b/crates/rustapi-rs/examples/mcp_tools.rs @@ -2,9 +2,10 @@ //! Native MCP server so that LLMs / agents (Claude, Cursor, etc.) can discover //! and call your endpoints as tools. //! +//! Uses `InvocationMode::InProcess` for zero-overhead tool calls (still +//! goes through full validation + middleware). +//! //! 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 @@ -28,7 +29,9 @@ //! -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::{run_rustapi_and_mcp_with_shutdown, McpConfig, McpServer}; +use rustapi_rs::protocol::mcp::{ + run_rustapi_and_mcp_with_shutdown, InvocationMode, McpConfig, McpServer, +}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Schema)] @@ -87,7 +90,8 @@ async fn main() -> Result<(), Box> { .name("rustapi-mcp-demo") .version("0.1.0") .description("Demo RustAPI instance exposing selected endpoints to AI agents via MCP") - .allowed_tags(["agent"]), + .allowed_tags(["agent"]) + .invocation_mode(InvocationMode::InProcess), ); let http_addr = "0.0.0.0:8080"; diff --git a/docs/cookbook/src/recipes/mcp_openapi_cli.md b/docs/cookbook/src/recipes/mcp_openapi_cli.md index 636e5b7e..9a3e2925 100644 --- a/docs/cookbook/src/recipes/mcp_openapi_cli.md +++ b/docs/cookbook/src/recipes/mcp_openapi_cli.md @@ -32,6 +32,10 @@ rustapi mcp generate \ rustapi mcp generate \ --api http://localhost:8080 \ --port 9090 + +# Best for RustAPI projects: no flags needed! +# Auto-generates the OpenAPI spec from your code (no running server or openapi.json required) +rustapi mcp generate --stdio ``` ## Flags