mcp-authorization
Per-request schema-level authorization for MCP tool servers in Rust. Type definitions ARE authorization policies — and in Rust, authorization can be a compile-time guarantee.
The Problem
MCP servers expose tools to LLM clients via tools/list. Today, every user sees the same tool schemas. If a user lacks permission to use a feature, the best you can do is reject the call with an error after the LLM already knows the capability exists and tried to use it.
Schema-level authorization solves this by shaping the JSON Schema each user sees before they can act on it. Different users hitting the same endpoint receive different tool lists, different input fields, and different output variants — based on their permissions. An LLM cannot hallucinate options it was never shown.
In Ruby and TypeScript, can?(:flag) / ctx.can(flag) is a runtime policy check that could be forgotten. In Rust, Proof<C> is a zero-sized token that can only be obtained by verifying a capability — the compiler refuses to build code that skips the check.
Three Layers of Authorization
| Layer | What it gates | Mechanism |
|---|---|---|
| Tool visibility | Entire tools | .authorize("tool_name", "capability") on the server builder |
| Input field visibility | Individual input fields | #[requires("capability")] on struct fields |
| Output variant visibility | Union branches in output | #[requires("capability")] on enum variants |
Quick Start
Add to your Cargo.toml:
[]
= { = "https://github.com/onboardiq/mcp_authorization_rust" }
= { = "1.4", = ["server", "transport-io"] }
= "1"
= { = "1", = ["derive"] }
= { = "1", = ["full"] }
Define capabilities as zero-sized types, then annotate your schemas:
use ;
use JsonSchema;
use ;
// Capabilities are zero-sized types
;
;
// Input schema — fields gated by #[requires]
// Output schema — variants gated by #[requires]
The type-state guarantee — Proof<C> in function signatures:
At runtime, the proof is obtained from AuthContext:
let auth = new;
// Type-state in action
if let Some = auth. else
// This would not compile:
// server.reroute(???, &input) // error[E0061]: missing argument of type Proof<BackwardRouting>
Wire it up with AuthorizedServer:
let server = new
.
.authorize;
Operator calls tools/list and sees:
Manager calls tools/list and sees:
The operator's LLM never knows stage_id or reason exist.
API Reference
Capability trait
Implement on zero-sized types to define permissions:
;
Proof<C>
Zero-sized compile-time proof that capability C was verified. Cannot be constructed directly — only obtained via AuthContext::check() or AuthContext::require(). Compiles away entirely at runtime (size_of::<Proof<C>>() == 0).
AuthContext
Per-request authorization context. Built by middleware from JWT claims, headers, etc. Stored in rmcp's RequestContext::extensions.
let auth = new;
auth. // -> Option<Proof<Admin>>
auth. // -> Result<Proof<Admin>, McpError>
auth.has // -> bool (for runtime schema shaping)
#[derive(AuthSchema)]
Generates AuthSchemaMetadata from #[requires("capability")] annotations on struct fields or enum variants. Works alongside #[derive(JsonSchema)] — does not modify the type.
AuthorizedServer<S>
Wraps any rmcp ServerHandler. Intercepts list_tools to shape schemas per-user and call_tool to enforce tool-level gates. Delegates everything else to the inner handler.
new
.
.authorize
SchemaShaper
Standalone schema shaping for use outside the server wrapper:
let shaped = ;
let shaped = ;
Architecture
This crate extends rmcp (the official Rust MCP SDK) rather than forking it. The flow:
- HTTP middleware extracts user identity →
AuthContext, inserts into rmcp'sRequestContext::extensions AuthorizedServer.list_tools()readsAuthContextfrom extensions, callsAuthToolRegistry.materialize(auth)which filters tools by tool-level gates and shapes input/output schemas by removing fields/variants the user lacks capabilities forAuthorizedServer.call_tool()checks tool-level authorization, then delegates to the innerServerHandler- Inside tool handlers,
Extension<AuthContext>extractor provides the auth context.auth.check::<C>()returnsProof<C>which handler functions can require in their signatures
Schema generation uses schemars (compile-time, cached). Per-request work is only the #[requires] filtering — hash lookups against the user's capability set.
Ruby ↔ Rust Mapping
This crate is the Rust counterpart of mcp_authorization (Ruby gem). The concepts map directly:
| Ruby gem | Rust crate | Enforcement |
|---|---|---|
authorization :manage_workflows |
.authorize("tool", "cap") |
Tool hidden from list_tools |
@requires(:flag) on param |
#[requires("flag")] on field |
Field removed from JSON Schema |
@requires(:flag) on variant |
#[requires("flag")] on variant |
Variant removed from oneOf |
can?(:flag) — runtime, forgettable |
Proof<C> — compile-time, unforgettable |
error[E0277] if proof missing |
Integrating with Existing RBAC
The AuthContext interface is deliberately minimal. For systems with complex RBAC (role matrices, action keys, feature flags), construct it from whatever your auth layer provides:
// Example: from JWT claims
let auth = new;
// Example: from database lookup
let permissions = db.get_user_permissions.await?;
let auth = new;
Then insert into rmcp's extensions before the request reaches your handler (via HTTP middleware, transport hooks, etc.).
Requirements
- Rust 1.75+ (edition 2021)
- rmcp 1.4+
License
MIT