Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -221,7 +221,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error + Send + Sy

```toml
[dependencies]
api = { package = "rustapi-rs", version = "0.1.478" }
api = { package = "rustapi-rs", version = "0.1.507" }
```

```rust
Expand Down Expand Up @@ -283,7 +283,9 @@ Detailed architecture, recipes, and guides are in the [Cookbook](docs/cookbook/s
- [gRPC Integration Guide](docs/cookbook/src/crates/rustapi_grpc.md)
- [Recommended Production Baseline](docs/PRODUCTION_BASELINE.md)
- [Production Checklist](docs/PRODUCTION_CHECKLIST.md)
- [Examples](crates/rustapi-rs/examples/)
- [Internal Examples](crates/rustapi-rs/examples/)

**Full standalone examples** (including a complete MCP tool example with in-process invocation) live in the separate **[rustapi-rs-examples](https://github.com/Tuntii/rustapi-rs-examples)** repository.

---

Expand Down
10 changes: 9 additions & 1 deletion crates/cargo-rustapi/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ enum Commands {
/// Generate API client from OpenAPI spec
Client(ClientArgs),

/// MCP tools — turn any OpenAPI spec into an MCP server for agents
/// MCP tools — turn any OpenAPI spec into an MCP server for agents.
///
/// If no --spec/--url/--api is given, it will automatically generate
/// the OpenAPI spec from your current RustAPI project (no need for
/// a running server or pre-existing openapi.json).
#[cfg(feature = "mcp")]
#[command(subcommand)]
Mcp(McpCommands),
Expand Down Expand Up @@ -112,5 +116,9 @@ enum McpCommands {
/// Works with FastAPI, Express, Go, Spring, or any other API that
/// publishes an OpenAPI 3.x document. All tool calls are forwarded
/// (proxied) to the real backend.
///
/// Inside a RustAPI project you can simply run:
/// cargo rustapi mcp generate
/// without any flags — the spec will be auto-generated from your code.
Generate(McpGenerateArgs),
}
97 changes: 93 additions & 4 deletions crates/cargo-rustapi/src/commands/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub struct McpGenerateArgs {

/// Base URL of a running service. Will try to fetch <base>/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<String>,

Expand Down Expand Up @@ -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))?;
Expand Down Expand Up @@ -276,7 +287,11 @@ fn resolve_spec_source(args: &McpGenerateArgs) -> Result<String> {
let base = a.trim_end_matches('/');
return Ok(format!("{}/openapi.json", base));
}
anyhow::bail!("One of --spec <file>, --url <url>, or --api <base-url> 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<String> {
Expand All @@ -286,15 +301,25 @@ fn resolve_target(args: &McpGenerateArgs) -> Result<String> {
if let Some(a) = &args.api {
return Ok(a.clone());
}
anyhow::bail!("--target <backend-base-url> 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<OpenApiSpec> {
let content = if source.starts_with("http://") || source.starts_with("https://") {
// remote-spec is pulled in by the mcp feature
reqwest::get(source)
.await
.context("Failed to fetch spec over HTTP")?
.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")?
Expand Down Expand Up @@ -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<String> {
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::*;
Expand Down
22 changes: 22 additions & 0 deletions crates/rustapi-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -1523,6 +1541,8 @@ impl RustApi {
/// .await
/// ```
pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.maybe_dump_openapi();

// Hot-reload mode banner
self.print_hot_reload_banner(addr);

Expand Down Expand Up @@ -1560,6 +1580,8 @@ impl RustApi {
where
F: std::future::Future<Output = ()> + Send + 'static,
{
self.maybe_dump_openapi();

// Hot-reload mode banner
self.print_hot_reload_banner(addr.as_ref());

Expand Down
7 changes: 7 additions & 0 deletions crates/rustapi-core/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions crates/rustapi-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Meta, syn::Token![,]>::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(
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions crates/rustapi-mcp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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
}
}
Loading
Loading