top of page
Search

Secure your MCP Servers using OAuth 2.1 - Part 1.

MCP is finally growing up: we now have a real spec, a draft auth standard, and clear guidance for how remote servers should behave. This blog is a practical “living standard” for AI engineers who want to build remote MCP servers with proper authentication and authorization, including OAuth 2.1.

I’ll focus on:

  • What your MCP server APIs should expose

  • How to model resources, tools, and prompts

  • How authentication & authorization work in MCP (incl. OAuth 2.1)

  • A concrete example of a remote MCP server + OAuth 2.1 flow (in Python-ish pseudocode)

All of this is based on the official MCP spec (revision 2025-06-18) plus the authorization draft and tutorials, which are the current source of truth. (modelcontextprotocol.io)

1. MCP in one paragraph

MCP (Model Context Protocol) is an open, JSON-RPC–based protocol that connects LLM hosts (ChatGPT, Claude, your own agent platform) to external systems (servers) via a standardized set of capabilities:

Servers and clients speak JSON-RPC 2.0 messages, over transports like STDIO or Streamable HTTP (single endpoint like /mcp that supports POST/GET, optionally SSE). (modelcontextprotocol.io)

On top of this, the spec defines:

  • Lifecycle – initialize, capability negotiation, notifications/initialized, shutdown

  • Server features – tools/resources/prompts/ completions APIs (modelcontextprotocol.io)

  • Authorization – an HTTP-level OAuth 2.1–based framework for remote servers (modelcontextprotocol.io)

2. What APIs should an MCP server expose?

From the spec’s point of view, your server is a JSON-RPC endpoint that supports a small, well-defined set of methods. The MCP SDKs hide most of this, but it’s useful to think in terms of the wire-level API.

2.1 Core lifecycle API

These are foundational and MUST exist for any compliant server:

  • initialize – protocol version + capability negotiation

  • notifications/initialized – sent by the client when ready

  • ping (optional but common) – liveness check

The initialization step lets the server advertise which features it supports (tools/resources/prompts/logging/completion, plus per-feature flags like listChanged and subscribe). (modelcontextprotocol.io)

2.2 Tools API

Tools are the “action surface” of your server. The standard methods are: (modelcontextprotocol.io)

  • tools/list – discover tools (with pagination)

  • tools/call – invoke a tool with JSON-schema–validated params

  • notifications/tools/list_changed – optional; tells clients your tool set changed

Each tool definition includes:

  • name – unique identifier, stable over time

  • description – natural language description for the LLM / UX

  • inputSchema – JSON Schema for parameters

  • outputSchema (optional but recommended) – JSON Schema for results

  • metadata – extra hints (rate limits, tags, domains, etc.)

Design suggestion: think of tools as public functions in an SDK, not raw API endpoints. Aggregate low-level APIs into a few high-level tools that map to user intents.

2.3 Resources API

Resources are read-only “documents” or blobs the client can fetch and attach as context. Standard methods: (modelcontextprotocol.io)

  • resources/list – list available resources

  • resources/templates/list – discover parameterized resource templates

  • resources/read – read resource content(s) by URI

  • resources/subscribe / resources/unsubscribe – watch for updates

  • notifications/resources/updated – resource changed notification

Each resource has:

  • uri – globally unique within the server (file:///…, db://, ticket://…)

  • name, title, description

  • mimeType – text/markdown, application/json, etc.

  • Optional metadata (etag, lastUpdated, permission hints)

Design suggestion: model resources at a task-friendly level (e.g., “invoice PDF”, “support ticket conversation”, “K8s deployment manifest”) rather than raw rows or low-level API responses. Let tools handle mutation; keep resources mostly read-only.

2.4 Prompts API

Prompts are reusable templates that guide the model on how to work with your domain. Standard methods: (modelcontextprotocol.io)

  • prompts/list – discover prompts

  • prompts/get – retrieve a full prompt + argument schema

They typically define:

  • name, description

  • arguments – typed parameters (with completion support)

  • messages – an array of system/user/assistant messages with placeholders

Design suggestion: use prompts to encode best-practice workflows (“triage support ticket”, “generate RCA from logs”, “explain invoice variance”) so the LLM doesn’t reinvent process every call.

2.5 Utilities & niceties

Optional but very useful:

  • Completion: completion/complete for argument and URI autocompletion (modelcontextprotocol.io)

  • Logging: structured logs back to the client

  • Progress: progress notifications for long-running tools

  • Change notifications: *_list_changed for dynamic environments

3. What resources should you expose?

The spec doesn’t prescribe which resources you must expose; it tells you how to expose them. The “what” is product and security driven. A useful pattern is to categorize resources by purpose: (modelcontextprotocol.io)

  1. Reference data – static or slowly changing data:

    • API docs, schema docs, knowledge base articles

    • Product catalog, pricing tiers

  2. User-specific data (requires strong auth):

    • Customer’s projects, tickets, alerts, dashboards

    • Files, documents, logs, metrics slices

  3. Configuration & metadata:

    • Environments, clusters, organizations, permissions summaries

    • “Capabilities” documents describing which tools/resources are safe for which roles

When designing resources, think:

  • URI namespace – e.g.:

    • customer://{tenantId}/invoices/{invoiceId}

    • kb://articles/{slug}

    • alerts://cluster/{clusterId}/alert/{id}

  • Granularity – small enough to be composable, big enough to be semantically meaningful.

  • Access control – never expose URIs that the authenticated user shouldn’t see. We’ll come back to this in the auth section.

4. Authentication vs authorization in MCP

MCP’s security story is intentionally layered: (modelcontextprotocol.io)

  • Authentication – “Who is this client/user?”

  • Authorization – “What can they do & see?”

And it’s transport-dependent:

  • STDIO transport (local, like desktop apps):

    • Spec says: do not use the HTTP auth framework.

    • Servers obtain credentials from environment, local OS APIs, or embedded SDKs. (modelcontextprotocol.io)

  • HTTP / Streamable HTTP transport (remote servers):

    • Spec defines a standard OAuth 2.1–based auth framework.

    • Uses OAuth 2.0 Protected Resource Metadata (RFC 9728) for discovery. (modelcontextprotocol.io)

4.1 Standards MCP builds on

The MCP authorization spec explicitly aligns with: (modelcontextprotocol.io)

  • OAuth 2.1 (draft-ietf-oauth-v2-1-13) – flows, tokens, PKCE

  • OAuth 2.0 Authorization Server Metadata (RFC 8414) – discovery

  • OAuth 2.0 Protected Resource Metadata (RFC 9728) – how the resource (MCP server) describes itself & its auth servers

  • OAuth 2.0 Dynamic Client Registration (RFC 7591) – registering MCP clients

This is critical: instead of inventing a bespoke auth scheme, MCP is piggybacking on the Web’s existing OAuth stack.

5. How OAuth 2.1 works for MCP servers

Let’s walk through the happy path for a remote MCP server over HTTP.

5.1 Roles

Per the spec: (modelcontextprotocol.io)

  • MCP client → OAuth 2.1 client

  • MCP server → OAuth 2.1 resource server

  • Authorization server → OAuth 2.1 authorization server (could be Auth0, Okta, your own)

  • User → resource owner

5.2 First contact: 401 + Protected Resource Metadata

  1. The MCP client sends a regular MCP request to your server’s HTTP endpoint (/mcp) without an access token.

  2. Your MCP server responds with 401 Unauthorized and a WWW-Authenticate header that includes: (modelcontextprotocol.io)

    HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="my-mcp-server", resource="https://mcp.example.com", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

  3. The MCP client then fetches the Protected Resource Metadata document at the given URL, per RFC 9728. That JSON includes an authorization_servers field listing acceptable authorization servers for this MCP server. (modelcontextprotocol.io)

5.3 Authorization Server discovery

Using the protected resource metadata, the client now discovers the authorization server(s): (modelcontextprotocol.io)

  • It follows either:

    • OAuth 2.0 Authorization Server Metadata (RFC 8414) or

    • OpenID Connect Discovery 1.0

The authorization server’s metadata tells the client:

  • Authorization endpoint

  • Token endpoint

  • Supported scopes, code challenge methods, etc.

5.4 Client registration

Next, the client needs a client_id and redirect URI known to the authorization server. MCP encourages: (modelcontextprotocol.io)

  • Dynamic client registration (RFC 7591) where possible, or

  • Static registration for known, long-lived clients (e.g., Claude, ChatGPT, enterprise agent platform)

At registration, the client shares:

  • client_name – what will appear on consent screens

  • redirect_uris – where auth codes should be sent

  • (Optionally) logo, terms, privacy policy

5.5 OAuth 2.1 Authorization Code + PKCE flow

Now the usual OAuth 2.1 Authorization Code flow kicks in: (mcp blog)

  1. MCP client generates a code verifier and code challenge (PKCE).

  2. MCP client opens the user’s browser to:

    GET https://auth.example.com/authorize ?response_type=code &client_id=... &redirect_uri=... &scope=mcp.read mcp.write &code_challenge=... &code_challenge_method=S256

  3. User sees the consent screen (“Allow ‘My AI Agent’ to access ‘My MCP Server’?”) and approves.

  4. Authorization server redirects back to the client’s redirect_uri with code=....

  5. Client exchanges code for tokens:

    POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=...& redirect_uri=...& code_verifier=...

  6. Authorization server returns an access token (and optionally a refresh token):

    { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9....", "token_type": "Bearer", "expires_in": 3600, "scope": "mcp.read mcp.write", "id_token": "...optional..." }

The MCP client caches this token for subsequent calls.

5.6 Authenticated MCP calls

From here on, the client attaches Authorization: Bearer <access_token> to every HTTP request to your MCP endpoint. (modelcontextprotocol.io)

Your server must:

  • Verify:

    • Signature (via JWKS or introspection)

    • Issuer (iss)

    • Audience (aud) – must match your MCP resource server identifier

    • Expiration (exp), not-before (nbf)

    • Scopes / claims

  • Reject tokens not intended for this server (wrong aud) or expired tokens. Token passthrough to upstream APIs without validation is explicitly discouraged in the security best practices. (modelcontextprotocol.io)

6. Authorization: mapping tokens to privileges

Authentication tells you who; authorization defines what.

A typical pattern for an MCP server:

  1. Map token → principal

    After validation, extract:

    • sub – user or service identifier

    • scope – coarse permissions (mcp.read, mcp.write, mcp.admin)

    • Additional custom claims (tenant_id, org_roles, feature_flags)

  2. Apply to MCP primitives

    • Tools:

      • Some tools only available to admins (reboot_cluster, delete_project)

      • Others available read-only vs read+write (create_ticket vs get_ticket)

    • Resources:

      • Filter resources/list results to only show accessible URIs

      • For resources/read, enforce row-level / document-level access using tenant IDs or ACLs

    • Prompts:

      • Hide specialized prompts that assume privileges the user lacks (e.g., “run production chaos test”)

  3. Encode policy

    You can represent policy as:

    • Static configuration (YAML/JSON)

    • Database-backed ABAC/RBAC

    • Code-level decorators around tool/resource handlers

7. Example: a remote MCP server with OAuth 2.1

Let’s sketch a minimal remote MCP server over Streamable HTTP with OAuth 2.1, using:

  • Python

  • An MCP server SDK (conceptually mcp / FastMCP style) (GitHub)

  • Any standard OAuth provider (Auth0, Okta, your own implementation) conforming to RFC 8414 / OIDC discovery (modelcontextprotocol.io)

⚠️ This is intentionally simplified to highlight where MCP and OAuth plug together. Adapt to your actual SDK and framework.

7.1 High-level architecture

7.2 Protected Resource Metadata (served by MCP server)

Example (JSON served at /.well-known/oauth-protected-resource):

{
  "resource": "https://mcp.example.com",
  "authorization_servers": [
    "https://auth.example.com"
  ],
  "resource_scopes": [
    "mcp.read",
    "mcp.write"
  ]
}

This is how MCP clients learn where to go for OAuth. (modelcontextprotocol.io)

7.3 A tiny server skeleton (Python-ish)

# server.py
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from typing import Dict, Any
import jwt  # PyJWT or any JOSE lib
import httpx

# Pseudo MCP SDK objects – adapt to your actual SDK
from mcp_server import McpServer, ToolContext

AUTH_ISSUER = "https://auth.example.com"
RESOURCE_ID = "https://mcp.example.com"
JWKS_URL = f"{AUTH_ISSUER}/.well-known/jwks.json"

app = FastAPI()
mcp = McpServer(
    name="remote-weather-mcp",
    version="1.0.0",
    capabilities={
        "tools": {"listChanged": False},
        "resources": {},
        "prompts": {}
    }
)

# --- Tools ---------------------------------------------------------

@mcp.tool(name="get_weather", description="Get current weather for a city")
async def get_weather(ctx: ToolContext, city: str) -> Dict[str, Any]:
    # ctx.principal contains the authenticated user info
    user = ctx.principal
    # Call your backend or third-party API here...
    return {
        "city": city,
        "temperature_c": 22.0,
        "condition": "Sunny",
        "requested_by": user["sub"]
    }

# --- Auth helpers --------------------------------------------------

async def fetch_jwks():
    async with httpx.AsyncClient() as client:
        resp = await client.get(JWKS_URL)
        resp.raise_for_status()
        return resp.json()

async def verify_access_token(authorization_header: str) -> Dict[str, Any]:
    if not authorization_header or not authorization_header.startswith("Bearer "):
        raise HTTPException(status_code=401, headers={
            "WWW-Authenticate": (
                'Bearer realm="remote-weather-mcp", '
                f'resource="{RESOURCE_ID}", '
                'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
            )
        })

    token = authorization_header[len("Bearer ") :]

    # In production, cache JWKS and handle key rotation.
    jwks = await fetch_jwks()
    # Pseudo-code: select key from JWKS and verify
    try:
        claims = jwt.decode(
            token,
            key=... ,               # Derived from JWKS
            algorithms=["RS256"],
            audience=RESOURCE_ID,
            issuer=AUTH_ISSUER
        )
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    return claims

# --- MCP endpoint (Streamable HTTP: single path) -------------------

@app.post("/mcp")
async def mcp_endpoint(request: Request):
    # 1. Authenticate
    claims = await verify_access_token(request.headers.get("Authorization"))

    # 2. Parse JSON-RPC request
    body = await request.json()

    # 3. Dispatch to MCP server with principal info
    #    (Exact method name depends on SDK; treat as pseudocode)
    result = await mcp.handle_json_rpc(body, principal=claims)

    # 4. Return JSON-RPC response
    return JSONResponse(result)

# Optional: support GET/SSE for streaming per Streamable HTTP spec.

Key points relative to the spec:

  • The server always enforces Bearer auth on /mcp.

  • If the header is missing/invalid, it responds with 401 + WWW-Authenticate that includes resource_metadata so MCP clients know how to initiate OAuth. (modelcontextprotocol.io)

  • During token verification we check:

    • iss (issuer = auth server)

    • aud (audience = this MCP server’s resource ID)

  • The decoded claims are passed into tool handlers as ctx.principal, enabling fine-grained authorization.

7.4 Connecting from an MCP client

On the client side (Claude / ChatGPT / your own agent platform), the user config generally looks like:

servers:
  - name: remote-weather-mcp
    url: https://mcp.example.com/mcp

A modern MCP-aware client will:

  1. Try a request without auth → receive 401 + WWW-Authenticate.

  2. Fetch the protected resource metadata at resource_metadata.

  3. Discover the authorization server via RFC 8414 / OIDC.

  4. Run the OAuth 2.1 Authorization Code + PKCE flow in the browser. (modelcontextprotocol.io)

  5. Store the access token and send it as Authorization: Bearer ... on future MCP calls.

From the user’s perspective, this surfaces as “Connect this MCP server” → OAuth login → tools/resources become available.

8. Practical recommendations for “latest-style” MCP servers

Given where the spec and roadmap are today, a “modern” remote MCP server should: (modelcontextprotocol.io)

  1. Adopt Streamable HTTP as your primary transport for remote hosting, and respect its security requirements:

    • Validate Origin header

    • Prefer binding to localhost when local

    • Require authentication for all HTTP connections

  2. Implement OAuth 2.1 properly:

    • Serve a correct Protected Resource Metadata document

    • Use RFC 8414 / OIDC discovery for your authorization server

    • Support Authorization Code with PKCE

    • Validate tokens and reject wrong aud or stale tokens

  3. Avoid token passthrough anti-patterns:

    • Never blindly pass client tokens to upstream APIs

    • If you call upstream APIs, be an OAuth client there and get separate tokens for upstream resources

  4. Design your primitives with control in mind:

    • Tools: model-controlled, minimal and high-level

    • Resources: application-controlled, strongly scoped by user and tenant

    • Prompts: user-controlled, explicit workflows

  5. Lean on official SDKs:

    • Use the official MCP SDKs (TypeScript, Python, Go, Java, etc.) so you don’t have to implement JSON-RPC plumbing and capability negotiation by hand. (modelcontextprotocol.io)

If you’d like, next step we can turn this into:

  • A concrete FastMCP-based implementation with proper auth hooks, or

  • A design doc for your org’s remote MCP architecture (multi-tenant, rate-limited, with audit logging and per-tool scopes).

 
 
 

Comments


Contact Us

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

FAQ's

© 2025 by Gradient Web Designers.

Our Location

bottom of page