Rust

TL;DR — Three crates on crates.io. authplane-mcp is axum middleware for servers built on the official Rust MCP SDK (rmcp): it verifies the bearer or DPoP-bound token and puts the verified claims on the request. authplane-fastmcp plugs a verifier into fastmcp-rust, bearer tokens only. authplane-sdk is the framework-agnostic core both sit on. Requires Rust 1.91+ (edition 2024) and a Tokio runtime.

Install

cargo add authplane-sdk authplane-mcp      # rmcp on axum
cargo add authplane-sdk authplane-fastmcp  # fastmcp-rust
cargo add authplane-sdk                    # core primitives only

authplane-mcp targets rmcp 1.4 with axum 0.8; authplane-fastmcp targets fastmcp-rust 0.3. API reference for each crate is on docs.rs: authplane-sdk, authplane-mcp, authplane-fastmcp.

authplane-mcp — official Rust MCP SDK (rmcp)

Quickstart

use std::sync::Arc;
use authplane_mcp::{AuthplaneMcpAuth, authplane_mcp_auth_middleware};
use authplane_sdk::{AuthplaneClient, FetchSettings};
use axum::{Json, Router, middleware::from_fn_with_state, routing::get};

let client = AuthplaneClient::create("https://auth.company.com", FetchSettings::default()).await?;
let scopes = vec!["tools/add".to_string(), "tools/multiply".to_string()];
let verifier = client.resource("https://mcp.company.com/mcp", &scopes).await?;
let prm = verifier.prm_response();

let auth = AuthplaneMcpAuth::new(Arc::new(verifier), "https://mcp.company.com".parse()?);

let app = Router::new()
    // PRM must be reachable without a token, so it sits outside the middleware.
    .route(
        "/.well-known/oauth-protected-resource/mcp",
        get(move || async move { Json(prm.clone()) }),
    )
    .nest_service(
        "/mcp",
        tower::ServiceBuilder::new()
            .layer(from_fn_with_state(auth, authplane_mcp_auth_middleware))
            .service(build_mcp_service()), // your rmcp StreamableHttpService
    );

AuthplaneClient::create performs RFC 8414 discovery; client.resource(...) returns the per-resource verifier. The middleware rejects a request with the right 401 / 403 / 503 and a WWW-Authenticate challenge carrying resource_metadata (RFC 9728 §5.1), so an MCP client can find the authorization server from the 401 alone. On success it inserts VerifiedClaims and RawAccessToken into the request extensions.

Unlike most adapters here, the PRM document is not served for you — mount verifier.prm_response() on its own route, as above.

Why resource_origin is required

AuthplaneMcpAuth::new takes the canonical origin your resource advertises. DPoP htu is checked against that origin, not the inbound Host header, so a misconfigured reverse proxy cannot shift it.

Scope enforcement

Read the claims from the request extensions inside the tool handler:

let claims = parts.extensions.get::<authplane_sdk::VerifiedClaims>().ok_or(/* … */)?;
claims.require_scope("tools/admin")?; // VerifierError::InsufficientScope → HTTP 403

Inbound DPoP

Set ResourceOptions::inbound_dpop and build the resource with client.resource_with_options(...). None (default) accepts bearer tokens only; Some(InboundDPoPOptions::default()) accepts both; Some(InboundDPoPOptions::required()) requires DPoP. The middleware drives the full verify_with_context pipeline either way.

authplane-fastmcp — fastmcp-rust

use std::sync::Arc;
use authplane_fastmcp::AuthplaneFastMcpTokenVerifier;
use fastmcp_rust::TokenAuthProvider;

let verifier = client.resource(&resource, &scopes).await?;
let provider = TokenAuthProvider::new(AuthplaneFastMcpTokenVerifier::new(Arc::new(verifier))?);

Bearer tokens only

fastmcp-rust 0.3 drops the HTTP headers before its auth hook runs, so the DPoP proof and the request URL never reach the verifier. A DPoP-bound token is rejected, not downgraded: it gets JSON-RPC -32002 rather than being accepted without its sender constraint. For DPoP, use authplane-mcp.

The same limitation means there is no WWW-Authenticate header to carry resource_metadata; a rejection carries it in the JSON-RPC error data instead ({"resource_metadata": "<url>"}). Serve the PRM document at that URL as usual.

authplane-sdk — core primitives

use authplane_sdk::{AuthplaneClient, FetchSettings};

let client = AuthplaneClient::create("https://auth.example.com", FetchSettings::default()).await?;
let resource = client.resource("https://api.example.com/mcp", &["tools/echo".to_string()]).await?;

let claims = resource.verify(&access_token).await?;
claims.require_scope("tools/echo")?;

FetchSettings::from_dev_mode(true) allows http://localhost and private networks for local development; the default is HTTPS-only with SSRF protection. Only RS256 and ES256 are accepted.

Token exchange

use authplane_sdk::TokenExchangeOptions;

let exchanged = client
    .exchange_token("my-resource-server", &client_secret, &TokenExchangeOptions {
        subject_token: inbound_token.to_string(),
        scope: "downstream/write".to_string(),
        resources: vec!["https://downstream.example".to_string()],
        ..Default::default()
    }, None)
    .await?;

A consent failure comes back as AuthplaneError::ConsentRequired; wrap the call in authplane_mcp::wrap_tool_with_url_elicitation (or the authplane_fastmcp equivalent) to turn it into MCP’s -32042 URL elicitation. access_denied (the exchanging client is not allowlisted on the target Resource) and invalid_target arrive as AuthplaneError::Auth, with is_access_denied() / is_invalid_target() to tell them apart; neither counts toward the circuit breaker.

Introspection revocation

Pass ResourceOptions { revocation: Some(RevocationConfig { client_id, client_secret, fail_open }), .. }. The client must be confidential and either the issuing client or a runtime-client of the Resource — since authserver 0.1.2 any other caller gets active: false for every token. Empty credentials are refused when the resource is built.

Errors

verify returns VerifiedClaims or a VerifierError: TokenMissing, TokenExpired, InvalidSignature, InvalidClaims, InsufficientScope, TokenRevoked, MetadataUnavailable, JwksUnavailable, and the DPoP variants (DpopProofMissing, DpopReplayDetected, DpopMultipleProofs, DpopBindingMismatch, DpopNotSupported). http_status(&error) and resource.www_authenticate(&error, realm) build the matching response. In 0.1.0 the challenge’s error_description still carries the error message, unlike the other SDKs’ fixed sentences.

Resource identifiers

The identifier is validated when the resource is built: it must be an absolute URL with a scheme and a host, and must not contain a fragment. An invalid one fails at startup, not at the first 401.