Secure your MCP Servers using OAuth 2.1 - Part 1.
- amareshpat
- Nov 11
- 9 min read
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:
Tools – executable functions the model can call (model-controlled) (modelcontextprotocol.io)
Resources – contextual data exposed as URIs (application-controlled) (modelcontextprotocol.io)
Prompts – reusable, parameterized templates (user-controlled) (modelcontextprotocol.io)
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)
Reference data – static or slowly changing data:
API docs, schema docs, knowledge base articles
Product catalog, pricing tiers
User-specific data (requires strong auth):
Customer’s projects, tickets, alerts, dashboards
Files, documents, logs, metrics slices
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
The MCP client sends a regular MCP request to your server’s HTTP endpoint (/mcp) without an access token.
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"
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)
MCP client generates a code verifier and code challenge (PKCE).
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
User sees the consent screen (“Allow ‘My AI Agent’ to access ‘My MCP Server’?”) and approves.
Authorization server redirects back to the client’s redirect_uri with code=....
Client exchanges code for tokens:
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=...& redirect_uri=...& code_verifier=...
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:
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)
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”)
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
https://mcp.example.com/.well-known/oauth-protected-resource
JSON describing this MCP resource + its authorization_servers (RFC 9728)
https://auth.example.com/.well-known/oauth-authorization-server
Authorization server metadata (RFC 8414 / OIDC discovery)
Streamable HTTP MCP endpoint (POST for JSON-RPC, GET/SSE for streaming), protected by Bearer tokens (modelcontextprotocol.io)
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:
Try a request without auth → receive 401 + WWW-Authenticate.
Fetch the protected resource metadata at resource_metadata.
Discover the authorization server via RFC 8414 / OIDC.
Run the OAuth 2.1 Authorization Code + PKCE flow in the browser. (modelcontextprotocol.io)
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)
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
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
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
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
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