Engineering Deep Dive

Connecting Claude.ai to Remote MCP Servers
via Microsoft Entra OAuth 2.0

A complete walkthrough of the OAuth discovery flow, the pitfalls we hit hosting multiple MCP servers behind a private network, and the production-ready Python solution.

⏱ 15 min read 🔒 OAuth 2.0 · MCP · Microsoft Entra 🐍 Python · FastAPI · Uvicorn

Overview

The Model Context Protocol (MCP) lets Claude.ai talk to external tools and data sources over HTTP. When those servers live inside a corporate private network, you need to layer in proper authentication. Our organisation uses Microsoft Entra ID (formerly Azure AD) as the identity provider, and we wanted Claude's connector to authenticate through it — without exposing credentials.

This post covers two distinct scenarios we went through:

⚠ Problem A Single global authorization for all MCP servers — the discovery path Claude follows doesn't match the standard resource-specific endpoint structure.
ℹ Problem B Different authorization servers for different MCP endpoints (HR vs Compliance) — requires careful resource matching and callback orchestration.
Architecture — Claude.ai ↔ Private MCP Network
Claude.ai MCP Client Auth Proxy /authorize /token /oauth/callback MCP Server — HR example.com/FirstMCPServer MCP Server — Compliance example.com/SecondMCPServer Microsoft Entra login.microsoftonline.com OAuth flow REST REST Entra auth MCP Client/Server Auth Proxy (our code) Microsoft Entra ID OAuth redirect
OAuth 2.0 Primer

OAuth 2.0 — A Simple Overview

Before diving into the proxy mechanics, it's worth grounding ourselves in how OAuth 2.0 Authorization Code Flow works — because every design decision in this project traces back to a specific part of this protocol.

OAuth 2.0 (RFC 6749) is an authorization framework — it lets a third-party application (Claude.ai) obtain limited access to a service (your MCP server) on behalf of a user, without the user handing over their password. It defines four roles:

RoleIn our setupWhat it does
Resource OwnerThe employee / userGrants permission to access their data
ClientClaude.aiRequests access on behalf of the user
Authorization ServerMicrosoft Entra IDIssues tokens after authenticating the user
Resource ServerYour MCP ServerValidates tokens and serves protected data

The Authorization Code Flow — step by step

OAuth 2.0 Authorization Code Flow — Simplified
Client (Claude) Auth Server Resource Server ① GET /authorize?response_type=code&client_id=…&scope=… User logs in + consents ② Redirect → redirect_uri?code=AUTH_CODE&state=… ③ POST /token code=AUTH_CODE&grant_type=authorization_code ④ { access_token, token_type, expires_in, … } ⑤ GET /resource Authorization: Bearer <access_token> Validate JWT signature ⑥ 200 OK — protected resource data Key Parameters in This Flow client_id — identifies the application with the auth server redirect_uri — where the auth code is sent after user login (must be pre-registered) scope — what permissions the client is requesting (e.g. api://app-id/read) state — opaque value to prevent CSRF; echoed back unchanged resource — (RFC 8707, optional) narrows token to a specific protected resource code_challenge / S256 — PKCE; prevents auth code interception attacks

The optional resource parameter (RFC 8707)

RFC 8707 — Resource Indicators for OAuth 2.0 adds an optional resource parameter to /authorize and /token. It tells the authorization server which API the token is intended for, so the server can embed the correct audience (aud) claim in the JWT.

Claude sends this parameter automatically, using the MCP server URL as the resource value. Microsoft Entra validates the resource against the App ID URI registered in the app manifest — and that's exactly where the mismatch that drives all of our proxy logic originates.

💡 Why PKCE matters Proof Key for Code Exchange (RFC 7636) prevents an attacker from stealing the auth code in transit. Claude uses S256 (SHA-256 challenge method). Your Entra app must have PKCE enabled — tick "Require PKCE" in the Authentication blade.

OAuth 2.0 Discovery — how clients find the auth server

Rather than hard-coding endpoint URLs, OAuth 2.0 clients use Authorization Server Metadata (RFC 8414) and Protected Resource Metadata (RFC 9728) — both served as JSON documents at well-known URLs. Claude.ai follows this discovery chain before initiating any auth flow, which is why getting those discovery endpoints right is the foundation of everything else in this guide.

Section A

Problem A — The Discovery Path Mismatch

When Claude.ai connects to a remote MCP server, it follows the OAuth 2.0 Protected Resource Metadata discovery sequence defined in RFC 9728. Here is the exact sequence of GET calls Claude makes:

Step 1 — Resource-specific protected resource metadata

Claude calls: GET https://example.com/.well-known/protected-resource/FirstMCPServer

This is the resource-scoped discovery endpoint. It should return a JSON document describing the auth server for FirstMCPServer specifically.

Step 2 — Global protected resource metadata (fallback #1)

If Step 1 returns 404, Claude falls back to: GET https://example.com/.well-known/protected-resource

This is the global catch-all. If it returns metadata, it applies to all resources under example.com.

Step 3 — Authorization server discovery (fallback #2)

Still nothing? Claude tries: GET https://example.com/.well-known/oauth-authorization-server

This is the classic OAuth 2.0 Authorization Server Metadata endpoint per RFC 8414.

Step 4 — Legacy authorize endpoint (final fallback)

As the absolute last resort, Claude hits: GET https://example.com/authorize

At this point any fine-grained per-resource control is lost — it's purely global.

Claude OAuth Discovery Fallback Chain
/.well-known/ protected-resource/ FirstMCPServer 404 /.well-known/ protected-resource (global fallback) 404 /.well-known/ oauth-authorization- server 404 /authorize Legacy fallback Global only ⚠ ① Resource-specific ② Global resource ③ AS metadata ④ Last resort Our server had this but with wrong resource name

🔴 The Core Problem

Our server served the protected-resource document at /.well-known/protected-resource (no path suffix), but Claude was looking for /.well-known/protected-resource/FirstMCPServer. Since the resource name in the document was also api://<Application-Id> rather than https://example.com/FirstMCPServer, Claude's resource matching failed and it fell all the way through to the global fallback.

Section A · Solution

Global Auth — Single Authorization for All Servers

If all your MCP servers share one Microsoft Entra application and you don't need per-server isolation, the simplest fix is to expose the standard OAuth Authorization Server Metadata (RFC 8414) at the well-known URL and let Claude's fallback mechanism find it.

What to serve at /.well-known/oauth-authorization-server

{
  "issuer":                "https://example.com",
  "authorization_endpoint": "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize",
  "token_endpoint":        "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token",
  "scopes_supported":      ["api://<app-id>/<your-scope>"],
  "jwks_uri":              "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys",
  "response_types_supported": ["code"],
  "grant_types_supported":   ["authorization_code"],
  "code_challenge_methods_supported": ["S256"]
}
💡 Quick Tip This approach is ideal for internal tooling where every employee uses the same Entra app. No proxy logic is needed — Claude finds the auth server via fallback and authenticates directly with Microsoft. Your MCP server only needs to validate the resulting JWT on every request.

Your MCP endpoint should return HTTP 401 WWW-Authenticate: Bearer for unauthenticated requests and 200 with the MCP response once the token is validated.

EndpointResponsibilityReturns
/.well-known/oauth-authorization-serverAuth server discoveryJSON metadata
/<MCPServer>MCP tool calls401 (unauth) / 200 (auth)
Section B

Problem B — Multiple MCP Servers, Different Auth

Our real scenario: two distinct MCP servers serving different departments, each needing its own Entra application (different scopes, different app registrations).

HR Server https://example.com/FirstMCPServer
Auth: FirstAuth Entra App
Scope: api://hr-app/read
Compliance Server https://example.com/SecondMCPServer
Auth: SecondAuth Entra App
Scope: api://compliance-app/read

OAuth 2.0 supports the optional resource parameter (RFC 8707) to scope a token to a specific protected resource. The problem: Claude sends the resource parameter along to Microsoft's /token and /authorize endpoints, but Entra validates that the resource matches the registered application URI. When the mismatch occurs, the whole auth flow fails.

🔴 Root Causes

1. Resource mismatch at /authorize — Claude sends resource=https://example.com/FirstMCPServer but the Entra app registration URI is api://<app-id>.

2. Callback mismatch — The redirect URI Claude uses must be registered exactly in the Entra app. Our proxy sits in between and must rewrite callbacks.

3. Scope validation at /token — Entra cross-validates scope against resource; sending both can cause failures.

Section B · Solution

The Full Solution Flow

✅ Key Insight

We introduce a lightweight Auth Proxy per authorization server. The proxy's job: intercept Claude's OAuth calls, strip/rewrite parameters that Entra rejects, inject the correct callback URI, and relay the result back to Claude. The MCP servers stay untouched — they just validate JWTs.

Step 1 — Resource-specific protected-resource metadata

We now correctly serve the resource-scoped endpoint. Claude hits:

GET https://example.com/.well-known/protected-resource/FirstMCPServer

and receives:

{
  "resource":             "https://example.com/FirstMCPServer",
  "authorization_servers": ["https://example.com/FirstAuth"],
  "scopes_supported":     ["api://hr-app/read"],
  "bearer_methods_supported": ["header"]
}

Now Claude knows exactly which authorization server to talk to for this resource.

Step 2 — Authorization server metadata

Claude follows up with:

GET https://example.com/.well-known/oauth-authorization-server/FirstAuth

We return our proxy's endpoints — not Microsoft's directly:

{
  "issuer":                "https://example.com/FirstAuth",
  "authorization_endpoint": "https://example.com/FirstAuth/authorize",
  "token_endpoint":        "https://example.com/FirstAuth/token",
  "scopes_supported":      ["api://hr-app/read"],
  "jwks_uri":              "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys",
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"]
}

Step 3 — /authorize proxy

Claude redirects the user's browser to our /FirstAuth/authorize. Our proxy:

Remove resource parameter

Entra validates resource against the app registration. We strip it to avoid mismatch errors.

Rewrite redirect_uri

Replace Claude's callback with our own: https://example.com/FirstAuth/oauth/callback (must be registered in Entra app).

Stash original Claude callback in session / state

We encode Claude's original redirect_uri + state in our own state parameter so we can replay it later.

Forward to Microsoft

Redirect to https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize.

Step 4 — /oauth/callback

Microsoft redirects back to our proxy callback with ?code=…&state=….

Decode original Claude callback from state

We extract Claude's real redirect_uri and original state.

Re-add resource parameter

Claude's callback expects the resource parameter to be present in the code exchange.

Forward code to Claude

Redirect to https://claude.ai/api/mcp/auth/callback?code=…&state=…&resource=…

Step 5 — /token proxy

Claude does a POST to exchange the code. Our proxy:

Strip scope parameter

When Entra sees both resource and scope in a token request it can reject the call. We remove scope and let Entra apply defaults from the app manifest.

Rewrite redirect_uri

Must match exactly what was used in /authorize — our proxy callback, not Claude's.

Call Microsoft /token

POST to https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token

Return access token to Claude

Relay the full token response JSON verbatim. Claude will attach it as Authorization: Bearer … on subsequent MCP calls.

Complete OAuth 2.0 Auth Code Flow — Claude → Proxy → Entra → MCP
Claude.ai Auth Proxy MS Entra MCP Server GET /.well-known/protected-resource/FirstMCPServer → resource + auth server URLs GET /.well-known/oauth-authorization-server/FirstAuth → proxy authorize + token endpoints GET /FirstAuth/authorize (user browser redirect) strip resource param rewrite redirect_uri → proxy callback Redirect to Entra /authorize User logs in + consents code → proxy callback decode state → Claude redirect_uri re-add resource param Redirect → claude.ai/api/mcp/auth/callback?code=… POST /FirstAuth/token (code exchange) strip scope, fix redirect_uri POST Entra /token access_token + id_token → token response to Claude POST /FirstMCPServer Authorization: Bearer <token> validate JWT + respond 200 OK — MCP tool result
Full Code

Complete Python Implementation

The following is a production-ready FastAPI application that implements the Auth Proxy for one MCP server. To add a second server (e.g., Compliance), duplicate the router with a different prefix and Entra app credentials. Run with Uvicorn.

requirements.txt
fastapi==0.110.0
uvicorn[standard]==0.29.0
httpx==0.27.0
python-jose[cryptography]==3.3.0
python-dotenv==1.0.1
mcp==1.3.0
.env
# Microsoft Entra — HR App
TENANT_ID=your-tenant-id
HR_CLIENT_ID=hr-entra-app-client-id
HR_CLIENT_SECRET=hr-entra-app-client-secret
HR_SCOPE=api://hr-app-id/read

# Microsoft Entra — Compliance App
COMPLIANCE_CLIENT_ID=compliance-entra-app-client-id
COMPLIANCE_CLIENT_SECRET=compliance-entra-app-client-secret
COMPLIANCE_SCOPE=api://compliance-app-id/read

# Server
BASE_URL=https://example.com
CLAUDE_CALLBACK_BASE=https://claude.ai/api/mcp/auth/callback
main.py — Auth Proxy + MCP Server
import os, json, urllib.parse, base64
from dotenv import load_dotenv
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from jose import jwt, JWTError
from jose.backends import RSAKey
import uvicorn
from mcp.server.fastmcp import FastMCP

load_dotenv()

TENANT_ID             = os.environ["TENANT_ID"]
BASE_URL              = os.environ["BASE_URL"]           # https://example.com
CLAUDE_CALLBACK_BASE  = os.environ["CLAUDE_CALLBACK_BASE"]

ENTRA_AUTH  = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize"
ENTRA_TOKEN = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
# Microsoft publishes signing keys at a well-known, stable URL per tenant.
# Map directly — no need for a dynamic JWKS URI lookup.
ENTRA_JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"

# Cache: loaded once at startup, refreshed only on key-not-found (kid mismatch)
_jwks_cache: dict | None = None

async def get_microsoft_keys(force_refresh: bool = False) -> dict:
    """Return Microsoft's public signing keys, using a simple in-process cache."""
    global _jwks_cache
    if _jwks_cache is None or force_refresh:
        async with httpx.AsyncClient() as c:
            resp = await c.get(ENTRA_JWKS_URL, timeout=5)
            resp.raise_for_status()
            _jwks_cache = resp.json()
    return _jwks_cache

# ── App config per MCP server ────────────────────────────────────────────────
APPS = {
    "FirstAuth": {
        "client_id":     os.environ["HR_CLIENT_ID"],
        "client_secret": os.environ["HR_CLIENT_SECRET"],
        "scope":         os.environ["HR_SCOPE"],
        "resource":      f"{BASE_URL}/FirstMCPServer",
    },
    "SecondAuth": {
        "client_id":     os.environ["COMPLIANCE_CLIENT_ID"],
        "client_secret": os.environ["COMPLIANCE_CLIENT_SECRET"],
        "scope":         os.environ["COMPLIANCE_SCOPE"],
        "resource":      f"{BASE_URL}/SecondMCPServer",
    },
}

app = FastAPI(title="MCP Auth Proxy")

# ── Helper: validate incoming Bearer token ───────────────────────────────────
async def verify_token(request: Request) -> dict:
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(
            status_code=401,
            headers={"WWW-Authenticate": 'Bearer realm="mcp"'},
            detail="Missing bearer token",
        )
    token = auth[7:]

    # Extract the key ID from the token header so we can pick the right key
    header = jwt.get_unverified_header(token)
    kid    = header.get("kid")

    # Try cached keys first; if kid is missing, force a refresh once
    # (Microsoft rotates keys periodically — this handles the transition)
    jwks = await get_microsoft_keys()
    known_kids = {k["kid"] for k in jwks.get("keys", [])}
    if kid not in known_kids:
        jwks = await get_microsoft_keys(force_refresh=True)

    try:
        # python-jose accepts a JWKS dict directly — it selects the key by kid
        claims = jwt.decode(token, jwks, algorithms=["RS256"],
                            options={"verify_aud": False})
        return claims
    except JWTError as e:
        raise HTTPException(status_code=401, detail=str(e))

# ═══════════════════════════════════════════════════════════════════════════
# DISCOVERY ENDPOINTS
# ═══════════════════════════════════════════════════════════════════════════

@app.get("/.well-known/protected-resource/{server_name}")
async def protected_resource_metadata(server_name: str):
    """Resource-specific protected resource metadata (RFC 9728)."""
    auth_map = {
        "FirstMCPServer":  "FirstAuth",
        "SecondMCPServer": "SecondAuth",
    }
    auth_name = auth_map.get(server_name)
    if not auth_name:
        raise HTTPException(status_code=404)
    cfg = APPS[auth_name]
    return JSONResponse({
        "resource":             cfg["resource"],
        "authorization_servers": [f"{BASE_URL}/{auth_name}"],
        "scopes_supported":     [cfg["scope"]],
        "bearer_methods_supported": ["header"],
    })

@app.get("/.well-known/oauth-authorization-server/{auth_name}")
async def as_metadata(auth_name: str):
    """Authorization Server Metadata (RFC 8414) — points at our proxy."""
    if auth_name not in APPS:
        raise HTTPException(status_code=404)
    cfg = APPS[auth_name]
    base = f"{BASE_URL}/{auth_name}"
    return JSONResponse({
        "issuer":                base,
        "authorization_endpoint": f"{base}/authorize",
        "token_endpoint":        f"{base}/token",
        "scopes_supported":      [cfg["scope"]],
        "jwks_uri":              ENTRA_JWKS_URL,
        "response_types_supported":           ["code"],
        "grant_types_supported":              ["authorization_code"],
        "code_challenge_methods_supported":   ["S256"],
    })

# ═══════════════════════════════════════════════════════════════════════════
# /authorize — strip resource, rewrite redirect, forward to Entra
# ═══════════════════════════════════════════════════════════════════════════

@app.get("/{auth_name}/authorize")
async def proxy_authorize(auth_name: str, request: Request):
    if auth_name not in APPS:
        raise HTTPException(status_code=404)
    cfg = APPS[auth_name]

    params = dict(request.query_params)

    # ① Stash Claude's original redirect_uri + state so we can replay later
    original_state = {
        "claude_redirect_uri": params.pop("redirect_uri", ""),
        "claude_state":        params.get("state", ""),
    }
    encoded_state = base64.urlsafe_b64encode(
        json.dumps(original_state).encode()
    ).decode()

    # ② Strip resource — Entra validates it against app registration URI
    params.pop("resource", None)

    # ③ Inject our callback + packed state
    params["redirect_uri"] = f"{BASE_URL}/{auth_name}/oauth/callback"
    params["state"]        = encoded_state
    params["client_id"]    = cfg["client_id"]

    entra_url = ENTRA_AUTH + "?" + urllib.parse.urlencode(params)
    return RedirectResponse(entra_url, status_code=302)

# ═══════════════════════════════════════════════════════════════════════════
# /oauth/callback — Microsoft redirects here; we forward to Claude
# ═══════════════════════════════════════════════════════════════════════════

@app.get("/{auth_name}/oauth/callback")
async def oauth_callback(auth_name: str, request: Request):
    if auth_name not in APPS:
        raise HTTPException(status_code=404)
    cfg = APPS[auth_name]

    params = dict(request.query_params)
    code  = params.get("code")
    state = params.get("state", "")

    # Decode the stashed state
    try:
        original = json.loads(base64.urlsafe_b64decode(state + "=="))
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid state")

    claude_redirect = original["claude_redirect_uri"]
    claude_state    = original["claude_state"]

    # Forward to Claude with code + resource re-added
    callback_params = {
        "code":     code,
        "state":    claude_state,
        "resource": cfg["resource"],
    }
    final_url = claude_redirect + "?" + urllib.parse.urlencode(callback_params)
    return RedirectResponse(final_url, status_code=302)

# ═══════════════════════════════════════════════════════════════════════════
# /token — strip scope, fix redirect_uri, forward to Entra
# ═══════════════════════════════════════════════════════════════════════════

@app.post("/{auth_name}/token")
async def proxy_token(auth_name: str, request: Request):
    if auth_name not in APPS:
        raise HTTPException(status_code=404)
    cfg = APPS[auth_name]

    body = dict(await request.form())

    # ① Strip scope to avoid Entra resource/scope conflict
    body.pop("scope", None)

    # ② Must match the redirect_uri used in /authorize
    body["redirect_uri"]   = f"{BASE_URL}/{auth_name}/oauth/callback"
    body["client_id"]      = cfg["client_id"]
    body["client_secret"]  = cfg["client_secret"]

    async with httpx.AsyncClient() as c:
        resp = await c.post(ENTRA_TOKEN, data=body)

    return JSONResponse(resp.json(), status_code=resp.status_code)

# ═══════════════════════════════════════════════════════════════════════════
# MCP SERVERS — protected with JWT validation
# ═══════════════════════════════════════════════════════════════════════════

# ── HR MCP Server ────────────────────────────────────────────────────────────
hr_mcp = FastMCP("HR MCP Server")

@hr_mcp.tool()
async def get_employee(employee_id: str) -> dict:
    """Return employee details from the HR system."""
    # Replace with real HR data source call
    return {"id": employee_id, "name": "Jane Smith", "department": "Engineering"}

@app.api_route("/FirstMCPServer", methods=["GET", "POST"])
async def hr_mcp_endpoint(request: Request):
    # Guard: 401 if no/invalid token
    await verify_token(request)
    # Delegate to FastMCP ASGI handler
    hr_asgi = hr_mcp.get_asgi_app()
    return await hr_asgi(request.scope, request.receive, request._send)

# ── Compliance MCP Server ─────────────────────────────────────────────────────
compliance_mcp = FastMCP("Compliance MCP Server")

@compliance_mcp.tool()
async def get_policy(policy_id: str) -> dict:
    """Return compliance policy details."""
    return {"id": policy_id, "title": "Data Retention", "status": "active"}

@app.api_route("/SecondMCPServer", methods=["GET", "POST"])
async def compliance_mcp_endpoint(request: Request):
    await verify_token(request)
    compliance_asgi = compliance_mcp.get_asgi_app()
    return await compliance_asgi(request.scope, request.receive, request._send)

# ═══════════════════════════════════════════════════════════════════════════
# ENTRY POINT
# ═══════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    uvicorn.run(
        "main:app",
        host="0.0.0.0",
        port=8000,
        reload=False,
        workers=4,
        # In production add: ssl_keyfile + ssl_certfile
    )
🚀 Running in Production Use uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile /etc/ssl/key.pem --ssl-certfile /etc/ssl/cert.pem --workers 4 behind an internal load balancer. Make sure your TLS certificate covers example.com and all subpaths.

Microsoft Entra App Registration Checklist

SettingValue
Redirect URI (HR app)https://example.com/FirstAuth/oauth/callback
Redirect URI (Compliance app)https://example.com/SecondAuth/oauth/callback
Supported account typesAccounts in this organisational directory only
API exposed — App ID URIapi://<client-id>
API exposed — Scoperead (or your custom scope name)
Token typeAccess tokens (v2 endpoint)
PKCERequired (S256)
Summary

Key Takeaways

Connecting Claude.ai to private MCP servers through Microsoft Entra is absolutely doable, but requires respecting the exact discovery URL structure Claude follows. The critical points:

Match the resource name exactly

The resource field in your /.well-known/protected-resource/<name> response must equal the MCP server URL Claude is calling — not your Entra app registration URI.

Use a proxy for parameter rewriting

Strip resource at /authorize, strip scope at /token, and always rewrite redirect_uri to your registered callback.

Encode state carefully

The proxy must stash Claude's original redirect and state in the OAuth state parameter and replay them faithfully after the Entra callback.

Always return 401 first

Your MCP endpoints must send HTTP 401 WWW-Authenticate: Bearer for unauthenticated requests — this is what tells Claude to start the OAuth flow.

References

References & Further Reading

Every design decision in this guide is rooted in one of the following specifications or official docs. Bookmark these — they're the ground truth when something breaks.

OAuth 2.0 Core Specifications

RFC / SpecTitleWhy it matters here
RFC 6749 The OAuth 2.0 Authorization Framework Foundation — defines roles, flows, grant types, and token types
RFC 6750 Bearer Token Usage Defines the Authorization: Bearer <token> header pattern MCP uses
RFC 7636 PKCE — Proof Key for Code Exchange Claude uses S256; Entra app must have PKCE enabled
RFC 8414 OAuth 2.0 Authorization Server Metadata The /.well-known/oauth-authorization-server discovery document Claude reads
RFC 8707 Resource Indicators for OAuth 2.0 The optional resource parameter Claude sends — root cause of the Entra mismatch
RFC 9728 OAuth 2.0 Protected Resource Metadata The /.well-known/protected-resource/<name> endpoint Claude checks first
RFC 9700 Best Current Practice for OAuth 2.0 Security Security recommendations: PKCE, state validation, exact redirect URI matching

Microsoft Entra ID (Azure AD) Docs

ResourceWhat you'll find there
Auth Code Flow (v2) Microsoft's own walkthrough of the authorization code flow against Entra, with exact parameter names
OIDC Discovery (common) Live JSON — all Entra endpoints, signing key URLs, supported scopes and claims
Access Token Reference Claims structure, signing algorithm (RS256), and how to validate JWTs issued by Entra
App Roles & Scopes How to define and expose API scopes (api://app-id/scope-name) in an Entra app registration
National Cloud Endpoints If you're on Azure Government or China cloud, the base URLs differ from the ones used in this guide

MCP & Claude Docs

ResourceWhat you'll find there
MCP Specification Official protocol spec — transport, message format, tool/resource/prompt primitives
MCP Transports HTTP + SSE transport details — how Claude communicates with remote MCP servers
Claude MCP Integration How to configure remote MCP servers in Claude.ai settings
MCP Python SDK The FastMCP class and mcp.run() used in our server implementation
📌 Quick Reference — Key Entra URLs Replace <tenant-id> with your Azure AD Directory (tenant) ID found in the Entra portal.

https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration