Skip to main content

OAuth 2.0 Resource Server framework for MCP (Model Context Protocol) servers

Project description

mcp-authflow-resource

OAuth 2.0 Resource Server framework for MCP servers. Validate tokens and control tool-call rates with a proportional feedback loop.

Pair with mcp-authflow on the authorization server side.

Features

  • Token verification via RFC 7662 introspection with SSRF protection
  • OAuth discovery endpoints (RFC 9728, RFC 8414, OIDC)
  • Friction control -- dynamic tool-call rate limiting using a proportional feedback loop
  • Response validation helpers for MCP tool implementations
  • ASGI middleware for path normalization and request logging
  • Async-first design, built on Starlette and MCP SDK

Installation

pip install mcp-authflow-resource

Quick Start: Protect an MCP Server in 5 Minutes

from mcp.server.fastmcp.server import FastMCP
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl

from mcp_authflow_resource import (
    IntrospectionTokenVerifier,
    register_oauth_discovery_endpoints,
)

# 1. Create a token verifier pointing at your auth server
verifier = IntrospectionTokenVerifier(
    introspection_endpoint="http://localhost:8000/introspect",
    server_url="https://mcp.example.com",
)

# 2. Create an MCP server with OAuth protection
app = FastMCP(
    name="My Protected MCP Server",
    token_verifier=verifier,
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        required_scopes=["read"],
        resource_server_url=AnyHttpUrl("https://mcp.example.com"),
    ),
)

# 3. Register OAuth discovery endpoints (RFC 9728 + RFC 8414)
register_oauth_discovery_endpoints(
    app,
    server_url="https://mcp.example.com",
    auth_server_public_url="https://auth.example.com",
    scopes=["read"],
)


# 4. Define tools -- they're now protected by OAuth
@app.tool()
async def hello(name: str) -> str:
    """Greet someone."""
    return f"Hello, {name}!"

That's it. Clients must now present a valid Bearer token to call any tool.

Architecture

  MCP Client
      |
      | Bearer token
      v
+---------------------------+
|    Resource Server         |   <-- this package
|    (your MCP tools)        |
|                           |
|  1. Extract Bearer token  |
|  2. Introspect token  ----+---> Auth Server (/introspect)
|  3. Check scopes          |         |
|  4. Friction check        |    "active": true/false
|  5. Execute tool          |
+---------------------------+

Token Verification Flow

  1. Client sends request with Authorization: Bearer <token> header
  2. IntrospectionTokenVerifier calls the auth server's introspection endpoint (RFC 7662)
  3. Auth server responds with token metadata (active, scope, client_id, exp, aud)
  4. If active and scopes match, the tool executes
  5. If validate_resource=True, the aud claim must match the server URL (RFC 8707)

API Reference

Token Verification

from mcp_authflow_resource import IntrospectionTokenVerifier

verifier = IntrospectionTokenVerifier(
    introspection_endpoint="http://auth-server:8000/introspect",
    server_url="https://mcp.example.com",
    validate_resource=True,  # Default; RFC 8707 resource binding. Set False only for single-RS deployments
)

# Returns AccessToken or None
token = await verifier.verify_token("Bearer_token_here")
# token.client_id, token.scopes, token.expires_at, token.resource

Authenticating to a Protected /introspect Endpoint

RFC 7662 §2.1 requires the introspection endpoint to authenticate its callers. Pass credentials via the optional client_id, client_secret, and client_auth_method keyword arguments — the verifier will include them on every introspection POST.

# Most common: HTTP Basic (RFC 6749 §2.3.1) — the default when credentials are given.
verifier = IntrospectionTokenVerifier(
    introspection_endpoint="https://auth.example.com/introspect",
    server_url="https://mcp.example.com",
    client_id="my-resource-server",
    client_secret="...",
    # client_auth_method="client_secret_basic"  (default)
)

# Form parameters in the POST body (RFC 6749 §2.3.1, alt form).
verifier = IntrospectionTokenVerifier(
    introspection_endpoint="https://auth.example.com/introspect",
    server_url="https://mcp.example.com",
    client_id="my-resource-server",
    client_secret="...",
    client_auth_method="client_secret_post",
)

# Single shared bearer secret (RFC 6750-style; used by mcp-authflow).
verifier = IntrospectionTokenVerifier(
    introspection_endpoint="https://auth.example.com/introspect",
    server_url="https://mcp.example.com",
    client_secret="shared-secret",
    client_auth_method="bearer",
)
client_auth_method Sent as Requires
"client_secret_basic" (default when client_secret set) Authorization: Basic base64(client_id:client_secret) client_id + client_secret
"client_secret_post" client_id and client_secret form fields in the POST body client_id + client_secret
"bearer" Authorization: Bearer <client_secret> client_secret
"none" (default when client_secret unset) no auth

SSRF Protection

from mcp_authflow_resource import is_safe_url

is_safe_url("https://api.example.com")           # True (HTTPS always safe)
is_safe_url("http://localhost:8000")              # True (localhost allowed by default)
is_safe_url("http://mcp-auth")                    # True (Docker/k8s service name)
is_safe_url("http://evil.example.com")            # False (HTTP to external host)
is_safe_url("http://localhost", allow_localhost=False)  # False

OAuth Discovery

Auto-configures .well-known endpoints so MCP clients can discover your auth server:

from mcp_authflow_resource import register_oauth_discovery_endpoints

register_oauth_discovery_endpoints(
    app,
    server_url="https://mcp.example.com",
    auth_server_public_url="https://auth.example.com",
    scopes=["read", "write"],
    resource_documentation="https://docs.example.com/mcp",
)

Registered endpoints:

Endpoint Spec
GET /.well-known/oauth-protected-resource RFC 9728
GET /mcp/.well-known/oauth-protected-resource RFC 9728 (path-scoped)
GET /.well-known/oauth-authorization-server RFC 8414
GET /.well-known/oauth-authorization-server/mcp RFC 8414 (path-scoped)
GET /.well-known/openid-configuration OIDC Discovery

Friction Control

Dynamic tool-call rate limiting that adjusts friction per-tool based on observed usage, converging toward configured targets. Inspired by proof-of-work difficulty adjustment.

Setup

from mcp_authflow_resource import (
    ControllerConfig,
    FrictionRegistry,
    ToolFrictionConfig,
    ToolGroupConfig,
    friction_controlled,
    init_friction,
    record_tool_call,
)

# Initialize at server startup
init_friction(FrictionRegistry(
    default_config=ControllerConfig(
        window_size=100,        # Sliding window of last 100 calls
        time_decay_rate=0.001,  # ~11.5 min half-life for idle decay
        warmup_calls=20,        # No adjustment during first 20 calls
    ),
    tool_configs={
        "delete_task": ToolFrictionConfig(target_rate=0.03),  # 3% of calls
        "update_task": ToolFrictionConfig(target_rate=0.10),  # 10% of calls
    },
    tool_groups={
        "mutations": ToolGroupConfig(
            tools=["delete_task", "update_task"],
            aggregate_target=0.20,  # Combined 20% of all calls
        ),
    },
))

Decorators

# Mutation tools: checks friction before execution, blocks if too high
@app.tool()
@friction_controlled()
async def delete_task(task_id: str) -> str:
    ...

# Read tools: records call without friction checks (for rate denominator)
@app.tool()
@record_tool_call()
async def get_tasks(status: str) -> str:
    ...

How It Works

The friction controller tracks tool calls in a sliding window and computes an exponential moving average (EMA) of each tool's usage rate. When a tool's rate exceeds its target, friction increases -- raising the cost and eventually blocking calls. When usage drops, friction decreases (2x faster than it rises).

Friction Level    Effect
---------------------------------------------------------------------------
0.0 - 0.59        NONE/LOW/MEDIUM -- tool executes normally
0.60 - 0.94       HIGH -- justification_required=True in FrictionResult
0.95 - 1.0        BLOCKED -- tool call denied, error returned

Key parameters:

Parameter Default Description
window_size 100 Number of recent calls to track
time_decay_rate 0.001 Exponential friction decay (~11.5 min half-life)
warmup_calls 20 Calls before friction adjustment begins
target_rate 0.05 Desired tool usage fraction (0.0-1.0)
justification_threshold 0.6 Friction level requiring justification
hard_block_threshold 0.95 Friction level that blocks the call
saturation_threshold 0.9 Triggers automatic relief if sustained

Observability

Friction events are emitted as structured JSON via Python's logging module:

# Logger names
"mcp_authflow_resource.friction"         # check/record events (INFO)
"mcp_authflow_resource.friction.block"   # blocked calls (WARNING)
"mcp_authflow_resource.friction.registry"  # client lifecycle (DEBUG)

Event types: friction_check, friction_block, friction_justification, friction_saturation

Fields: event_type, client_id, tool_name, friction_level, ema_rate, target_rate, cost, allowed

Response Validation

Helpers for validating API responses in MCP tool implementations:

from mcp_authflow_resource.validation import (
    json_error,
    validate_list_response,
    validate_dict_response,
)

# Returns (list, None) on success or ([], "error message") on failure
items, error = validate_list_response(api_response, context="tasks")
if error:
    return json_error(error)

Middleware

from mcp_authflow_resource.middleware import NormalizePathMiddleware, create_logging_middleware

# Normalize trailing slashes: /mcp/ -> /mcp
app.add_middleware(NormalizePathMiddleware)

# Debug logging with auth header masking
app = create_logging_middleware(app, mask_auth=True)

Full Example: Auth Server + Resource Server

A complete working example using both packages together:

auth_server.py (authorization server):

import secrets
import time
from contextlib import asynccontextmanager

from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route

def invalid_request(message: str) -> JSONResponse:
    return JSONResponse(
        {"error": "invalid_request", "error_description": message},
        status_code=400,
    )


def parse_scope_field(value: object) -> str:
    return " ".join(str(value or "").split())


class MemoryTokenStorage:
    def __init__(self) -> None:
        self._tokens: dict[str, dict[str, object]] = {}

    async def initialize(self) -> None:
        return None

    async def close(self) -> None:
        self._tokens.clear()

    async def store_token(
        self,
        *,
        token: str,
        client_id: str,
        scopes: list[str],
        expires_at: int,
    ) -> None:
        self._tokens[token] = {
            "client_id": client_id,
            "scopes": scopes,
            "expires_at": expires_at,
        }

    async def load_token(self, token: str) -> dict[str, object] | None:
        return self._tokens.get(token)


storage = MemoryTokenStorage()

async def token(request: Request) -> JSONResponse:
    form = await request.form()
    client_id = str(form.get("client_id", ""))
    if not client_id:
        return invalid_request("client_id is required")

    access_token = secrets.token_urlsafe(32)
    scopes = parse_scope_field(form.get("scope"))

    await storage.store_token(
        token=access_token,
        client_id=client_id,
        scopes=scopes.split(),
        expires_at=int(time.time()) + 3600,
    )

    return JSONResponse({
        "access_token": access_token,
        "token_type": "bearer",
        "expires_in": 3600,
        "scope": scopes,
    })

async def introspect(request: Request) -> JSONResponse:
    form = await request.form()
    token_str = str(form.get("token", ""))
    data = await storage.load_token(token_str)

    if not data or data["expires_at"] < time.time():
        return JSONResponse({"active": False})

    return JSONResponse({
        "active": True,
        "client_id": data["client_id"],
        "scope": " ".join(data["scopes"]),
        "exp": data["expires_at"],
    })

@asynccontextmanager
async def lifespan(app):
    await storage.initialize()
    yield
    await storage.close()

app = Starlette(
    routes=[
        Route("/token", token, methods=["POST"]),
        Route("/introspect", introspect, methods=["POST"]),
    ],
    lifespan=lifespan,
)

resource_server.py (MCP resource server):

from mcp.server.fastmcp.server import FastMCP

from mcp_authflow_resource import (
    IntrospectionTokenVerifier,
    register_oauth_discovery_endpoints,
)

verifier = IntrospectionTokenVerifier(
    introspection_endpoint="http://localhost:8000/introspect",
    server_url="http://localhost:8001",
)

app = FastMCP(name="Example MCP", token_verifier=verifier)

register_oauth_discovery_endpoints(
    app,
    server_url="http://localhost:8001",
    auth_server_public_url="http://localhost:8000",
)

@app.tool()
async def greet(name: str) -> str:
    """Say hello."""
    return f"Hello, {name}!"

Run both:

# Terminal 1: Auth server
uvicorn auth_server:app --port 8000

# Terminal 2: Resource server
python resource_server.py  # MCP SDK handles transport

Test the flow:

# Get a token
TOKEN=$(curl -s -X POST http://localhost:8000/token \
  -d "client_id=test&scope=read" | jq -r .access_token)

# Call an MCP tool (via the MCP protocol, token in Authorization header)
curl http://localhost:8001/.well-known/oauth-protected-resource

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mcp_authflow_resource-0.5.1.tar.gz (140.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mcp_authflow_resource-0.5.1-py3-none-any.whl (30.2 kB view details)

Uploaded Python 3

File details

Details for the file mcp_authflow_resource-0.5.1.tar.gz.

File metadata

  • Download URL: mcp_authflow_resource-0.5.1.tar.gz
  • Upload date:
  • Size: 140.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mcp_authflow_resource-0.5.1.tar.gz
Algorithm Hash digest
SHA256 3d02b1d49ded446e1f013058b2e4c4f9c4fa4e95aa62de91fd2c01a2089fcce6
MD5 6b6b7d9322b25e5cc4e653fb20f73a1e
BLAKE2b-256 a24092ee1d7f59c5fc4cb44a858a7f239e93921abcdc6d0b3f0a1324c4cd1505

See more details on using hashes here.

Provenance

The following attestation bundles were made for mcp_authflow_resource-0.5.1.tar.gz:

Publisher: publish.yml on brooksmcmillin/mcp-authflow-resource

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mcp_authflow_resource-0.5.1-py3-none-any.whl.

File metadata

File hashes

Hashes for mcp_authflow_resource-0.5.1-py3-none-any.whl
Algorithm Hash digest
SHA256 73ccb68bfe52329c4da34061e4390fd99de0c6ffd2e02d8055592683028a3815
MD5 25592cdd65d18138019772a817a5a51c
BLAKE2b-256 7a97de5f1cd260237a3d9e66c1d4e4c15e2dd302fff3022a895a68b73c900aad

See more details on using hashes here.

Provenance

The following attestation bundles were made for mcp_authflow_resource-0.5.1-py3-none-any.whl:

Publisher: publish.yml on brooksmcmillin/mcp-authflow-resource

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page