Skip to content

onboardiq/mcp_authorization_rust

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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:

[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.

API Reference

Capability trait

Implement on zero-sized types to define permissions:

struct Admin;
impl Capability for Admin {
    const NAME: &'static str = "admin";
}

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 = 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)

#[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.

AuthorizedServer::new(inner_handler)
    .register::<InputType, OutputType>("tool_name", "description")
    .authorize("tool_name", "required_capability")

SchemaShaper

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);

Architecture

This crate extends rmcp (the official Rust MCP SDK) rather than forking it. The flow:

  1. HTTP middleware extracts user identity → AuthContext, inserts into rmcp's RequestContext::extensions
  2. AuthorizedServer.list_tools() reads AuthContext from extensions, calls AuthToolRegistry.materialize(auth) which filters tools by tool-level gates and shapes input/output schemas by removing fields/variants the user lacks capabilities for
  3. AuthorizedServer.call_tool() checks tool-level authorization, then delegates to the inner ServerHandler
  4. Inside tool handlers, Extension<AuthContext> extractor provides the auth context. auth.check::<C>() returns Proof<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 = 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.).

Requirements

  • Rust 1.75+ (edition 2021)
  • rmcp 1.4+

License

MIT

About

Type-state authorization for MCP tool servers in Rust — compile-time proof that authorization checks cannot be skipped

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages