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.
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.
| 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 |
Add to your Cargo.toml:
[dependencies]
mcp-authorization = { git = "https://github.com/onboardiq/mcp_authorization_rust" }
rmcp = { version = "1.4", features = ["server", "transport-io"] }
schemars = "1"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }Define capabilities as zero-sized types, then annotate your schemas:
use mcp_authorization::{Capability, Proof, AuthContext, AuthSchema, AuthorizedServer};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// Capabilities are zero-sized types
struct ManageWorkflows;
impl Capability for ManageWorkflows {
const NAME: &'static str = "manage_workflows";
}
struct BackwardRouting;
impl Capability for BackwardRouting {
const NAME: &'static str = "backward_routing";
}
// Input schema — fields gated by #[requires]
#[derive(Deserialize, JsonSchema, AuthSchema)]
struct AdvanceStepInput {
pub applicant_id: String,
pub workflow_id: String,
#[requires("backward_routing")]
pub stage_id: Option<String>, // managers only
#[requires("backward_routing")]
pub reason: Option<String>, // managers only
}
// Output schema — variants gated by #[requires]
#[derive(Serialize, JsonSchema, AuthSchema)]
#[serde(tag = "type")]
enum AdvanceStepOutput {
Success { applicant_id: String, current_stage: String },
#[requires("backward_routing")]
ReroutedSuccess { applicant_id: String, previous_stage: String, current_stage: String },
Error { code: String, message: String },
}The type-state guarantee — Proof<C> in function signatures:
impl WorkflowServer {
// This function CANNOT be called without proving BackwardRouting.
// The compiler enforces it. You cannot forget the auth check.
fn reroute(
&self,
_proof: Proof<BackwardRouting>, // zero-sized, compiles away
input: &AdvanceStepInput,
) -> AdvanceStepOutput {
// Can only reach here if BackwardRouting was proven
AdvanceStepOutput::ReroutedSuccess { /* ... */ }
}
}At runtime, the proof is obtained from AuthContext:
let auth = AuthContext::new(vec!["manage_workflows", "backward_routing"]);
// Type-state in action
if let Some(proof) = auth.check::<BackwardRouting>() {
server.reroute(proof, &input) // compiles — proof obtained
} else {
server.advance_forward(&input) // no proof needed
}
// This would not compile:
// server.reroute(???, &input) // error[E0061]: missing argument of type Proof<BackwardRouting>Wire it up with AuthorizedServer:
let server = AuthorizedServer::new(WorkflowServer)
.register::<AdvanceStepInput, AdvanceStepOutput>(
"advance_step",
"Advance an applicant in their workflow",
)
.authorize("advance_step", "manage_workflows");Operator calls tools/list and sees:
{
"name": "advance_step",
"inputSchema": {
"type": "object",
"properties": {
"applicant_id": { "type": "string" },
"workflow_id": { "type": "string" }
}
}
}Manager calls tools/list and sees:
{
"name": "advance_step",
"inputSchema": {
"type": "object",
"properties": {
"applicant_id": { "type": "string" },
"workflow_id": { "type": "string" },
"stage_id": { "type": "string" },
"reason": { "type": "string" }
}
}
}The operator's LLM never knows stage_id or reason exist.
Implement on zero-sized types to define permissions:
struct Admin;
impl Capability for Admin {
const NAME: &'static str = "admin";
}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).
Per-request authorization context. Built by middleware from JWT claims, headers, etc. Stored in rmcp's RequestContext::extensions.
let auth = AuthContext::new(vec!["manage_workflows", "admin"]);
auth.check::<Admin>() // -> Option<Proof<Admin>>
auth.require::<Admin>() // -> Result<Proof<Admin>, McpError>
auth.has("admin") // -> bool (for runtime schema shaping)Generates AuthSchemaMetadata from #[requires("capability")] annotations on struct fields or enum variants. Works alongside #[derive(JsonSchema)] — does not modify the type.
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.
AuthorizedServer::new(inner_handler)
.register::<InputType, OutputType>("tool_name", "description")
.authorize("tool_name", "required_capability")Standalone schema shaping for use outside the server wrapper:
let shaped = SchemaShaper::shape_input::<MyInput>(&auth_context);
let shaped = SchemaShaper::shape_output::<MyOutput>(&auth_context);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.
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 |
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 = AuthContext::new(jwt.capabilities.iter().cloned());
// Example: from database lookup
let permissions = db.get_user_permissions(user_id).await?;
let auth = AuthContext::new(permissions);Then insert into rmcp's extensions before the request reaches your handler (via HTTP middleware, transport hooks, etc.).
- Rust 1.75+ (edition 2021)
- rmcp 1.4+
MIT