top of page
Search

Secure your MCP Servers using OAuth 2.1 - Part 2

Here’s a concrete FastMCP-based setup that wires in real MCP-style OAuth 2.1 auth, including:

  • a remote HTTP MCP server

  • proper OAuth 2.1 / PRM (Protected Resource Metadata) wiring via FastMCP’s RemoteAuthProvider

  • token-based authorization hooks inside tools

  • a sample client using FastMCP’s OAuth helper to log in and call tools

I’ll assume you already have an external IdP (e.g. https://auth.example.com) that:

  • issues JWT access tokens

  • supports OAuth 2.1 + Authorization Server Metadata

  • supports Dynamic Client Registration (DCR), which is what MCP really wants for “automatic” client onboarding. (FastMCP)

1. Server: FastMCP + Remote OAuth 2.1

FastMCP already has the primitives you want:

  • JWTVerifier – validates JWT access tokens (issuer, audience, signatures, expiry). (FastMCP)

  • RemoteAuthProvider – exposes OAuth 2.0 Protected Resource Metadata (/.well-known/oauth-protected-resource) and ties it to your IdP. (FastMCP)

  • HTTP transport – to expose your MCP server at a URL like https://mcp.example.com/mcp. (FastMCP)

1.1 Install deps

uv pip install fastmcp "pydantic>=2"
# or: pip install fastmcp pydantic

1.2 server.py: a protected Weather MCP server

# server.py
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token, AccessToken
from pydantic import AnyHttpUrl


# --- Auth configuration -------------------------------------------------

# 1) How to validate incoming Bearer tokens (JWT from your IdP)
token_verifier = JWTVerifier(
    jwks_uri="https://auth.example.com/.well-known/jwks.json",
    issuer="https://auth.example.com",
    audience="https://mcp.example.com",  # this should match your PRM `resource`
)

# 2) How to advertise your IdP to MCP clients via PRM (RFC 9728)
auth = RemoteAuthProvider(
    token_verifier=token_verifier,
    authorization_servers=[AnyHttpUrl("https://auth.example.com")],
    base_url="https://mcp.example.com",   # canonical HTTPS origin of this server
    # Allow localhost redirect URIs for dev MCP clients (ChatGPT, FastMCP Client, etc.)
    allowed_client_redirect_uris=[
        "http://localhost:*",
        "http://127.0.0.1:*",
    ],
)

# --- FastMCP server -----------------------------------------------------

mcp = FastMCP(
    name="WeatherMCP",
    auth=auth,  # <- this turns on OAuth 2.1 / PRM for HTTP transport
)


# --- Example tool with authorization logic ------------------------------

@mcp.tool
async def current_weather(city: str) -> dict:
    """
    Return fake weather for demo.
    Requires `weather:read` scope on the access token.
    """

    token: AccessToken | None = get_access_token()
    if token is None:
        # FastMCP will already have rejected unauthenticated calls,
        # but this guards against misconfig / non-HTTP transports.
        raise PermissionError("Not authenticated")

    # Enforce scopes (or roles) from the token
    required_scope = "weather:read"
    if required_scope not in (token.scopes or []):
        raise PermissionError(f"Missing required scope: {required_scope}")

    user_id = token.claims.get("sub", token.client_id or "unknown")

    # In a real app you’d call an external API here.
    # This is just a stubbed response.
    return {
        "city": city,
        "conditions": "sunny",
        "temperature_c": 23.5,
        "requested_by": user_id,
        "scopes": token.scopes,
    }


# Optional: a resource that’s also protected
@mcp.resource("weather://cities")
async def list_supported_cities() -> list[str]:
    return ["San Francisco", "Berlin", "Tokyo", "Bangalore"]


if __name__ == "__main__":
    # HTTP transport exposes an MCP endpoint at /mcp
    # e.g. https://mcp.example.com/mcp in production
    mcp.run(transport="http", host="0.0.0.0", port=8000)

What this gives you:

  • RemoteAuthProvider automatically mounts:

    • /.well-known/oauth-protected-resource on your server (PRM)

    • 401 challenges with WWW-Authenticate: Bearer resource_metadata=... so MCP clients know where to fetch PRM. (FastMCP)

  • PRM returns something like:

    { "resource": "https://mcp.example.com", "authorization_servers": ["https://auth.example.com"], "bearer_methods_supported": ["header"], "scopes_supported": ["weather:read"] }

    (FastMCP builds this from base_url + authorization_servers + verifier config.) (FastMCP)

  • Incoming HTTP requests to /mcp must include a valid OAuth 2.1 Bearer token, or they get a 401 with a PRM pointer, per the MCP Authorization spec. (Model Context Protocol)

2. How OAuth 2.1 actually works in this setup

With this wiring, the flow is:

  1. MCP client calls POST https://mcp.example.com/mcp without a token → server responds 401 Unauthorized with WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" and often scope="weather:read". (Model Context Protocol)

  2. The MCP client fetches PRM from /.well-known/oauth-protected-resource to discover:

    • canonical resource

    • trusted authorization_servers

    • scopes, token format, etc. (Descope)

  3. Using PRM, the client picks an authorization server (e.g. https://auth.example.com) and fetches its Authorization Server Metadata (/.well-known/oauth-authorization-server or OIDC Discovery). (Model Context Protocol)

  4. Client runs OAuth 2.1 Authorization Code with PKCE:

    • Generates a code_verifier/code_challenge.

    • Opens browser to the IdP’s authorization endpoint with response_type=code, code_challenge, resource=https://mcp.example.com, and scope=weather:read.

    • User logs in, consents.

    • IdP redirects back to the client’s local redirect URI (http://127.0.0.1:<random-port>/callback).

    • Client swaps code for access_token (and refresh token) at the IdP’s token endpoint, using PKCE (no client secret needed). (FastMCP)

  5. The client caches tokens and adds Authorization: Bearer <access_token> in subsequent MCP calls.

FastMCP’s auth providers take care of:

  • verifying JWT signatures via the IdP’s JWKS (jwks_uri), issuer, audience, and expiry; (FastMCP)

  • validating tokens per request;

  • generating the protected resource metadata endpoints for MCP clients. (FastMCP)

3. Using auth claims & scopes in tools

Above we used get_access_token() to pull auth context into a tool. That’s the “auth hook” you’ll use most often. The pattern is:

from fastmcp.server.dependencies import get_access_token, AccessToken

@mcp.tool
async def admin_op() -> str:
    token: AccessToken | None = get_access_token()
    if token is None:
        raise PermissionError("Not authenticated")

    if "admin:write" not in (token.scopes or []):
        raise PermissionError("admin:write scope required")

    user_id = token.claims.get("sub") or token.client_id
    # Perform privileged operation...
    return f"OK for user {user_id}"

The AccessToken object gives you:

  • token.scopes – list of scopes granted

  • token.claims – raw JWT claims (sub, tenant_id, roles, etc.)

  • token.client_id – derived from standard claims like client_id or sub. (FastMCP)

This lets you implement:

  • tenant-aware tools (filter by tenant_id claim),

  • per-user ACLs (sub),

  • tool-level permission checks (scopes / roles).

4. Client: connecting with FastMCP’s OAuth helper

Now let’s show a simple Python client that connects to your protected server using OAuth 2.1.

FastMCP has an OAuth helper that:

  • discovers PRM from /.well-known/oauth-protected-resource,

  • discovers authorization server metadata,

  • does Authorization Code + PKCE,

  • runs a local callback server,

  • manages token refresh and storage. (FastMCP)

4.1 client.py: OAuth client calling the WeatherMCP server

# client.py
import asyncio
from fastmcp import Client
from fastmcp.client.auth import OAuth


async def main():
    mcp_url = "https://mcp.example.com/mcp"

    # Configure OAuth 2.1 client
    oauth = OAuth(
        mcp_url=mcp_url,
        scopes=["weather:read"],          # must match what your server expects
        client_name="Weather Demo Client" # optional, for DCR
        # token_storage=...               # plug in encrypted storage in real apps
    )

    # Connect using HTTP transport + OAuth 2.1
    async with Client(mcp_url, auth=oauth) as client:
        # Make sure we can talk to the server
        await client.ping()

        tools = await client.list_tools()
        print("Available tools:", [t.name for t in tools])

        # Call our protected tool
        result = await client.call_tool(
            name="current_weather",
            arguments={"city": "San Francisco"},
        )

        # result.content[0].text / .json etc depending on your return type
        print("Weather:", result.content[0].as_json())


if __name__ == "__main__":
    asyncio.run(main())

What happens when you run this:

  1. Client(..., auth=oauth) makes a call to mcp_url → gets 401 with WWW-Authenticate → fetches PRM. (FastMCP)

  2. OAuth helper follows the PRM’s authorization_servers to find IdP metadata.

  3. If no valid token exists in token_storage, it:

    • starts a local callback server (e.g. http://127.0.0.1:51123/callback),

    • opens your browser to the IdP login/consent screen,

    • exchanges the code for tokens, stores them, and continues.

  4. Subsequent runs reuse cached tokens until they expire, at which point OAuth refreshes them.

This is all fully compliant with MCP’s Authorization spec (OAuth 2.1, PRM, DCR) as long as your IdP implements those pieces. (Model Context Protocol)

5. Minimal “dev-only” alternative: JWT-only verification

If you just want a local dev server without the full browser OAuth flow, you can swap RemoteAuthProvider for JWTVerifier directly:

from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier

auth = JWTVerifier(
    jwks_uri="https://auth-dev.example.com/.well-known/jwks.json",
    issuer="https://auth-dev.example.com",
    audience="https://mcp-dev.example.com",
)

mcp = FastMCP(name="DevServer", auth=auth)

Then you manually mint tokens (e.g. with JWTVerifier.create_token) and pass them as Authorization: Bearer <token> from your own client. (FastMCP)

This still gives you proper token validation but skips PRM + DCR; so it’s not fully spec-compliant for ChatGPT / generic MCP clients, but it’s handy while you’re iterating.

6. Where to go deeper

If you want to push this into production, the next steps I’d explore:

  • Use a proper DCR-capable IdP (Descope, WorkOS AuthKit, modern OIDC servers) and configure it with RemoteAuthProvider. (FastMCP)

  • Or use OAuthProxy if your IdP doesn’t support DCR (GitHub / Google / Azure, etc.). (FastMCP)

  • Add middleware that maps token claims → app-level roles, and store them in ctx.set_state(...) for tools to consume. (FastMCP)

If you tell me which IdP you’re actually using (Auth0, WorkOS, Descope, Entra, etc.), I can adapt this example to that provider’s exact config and env vars.

 
 
 

Comments


Contact Us

Tel: 201-682-8623 / Email: support@gradientorbit.com

FAQ's

© 2025 by Gradient Web Designers.

Our Location

bottom of page