← All articles

MCP Failed to Discover OAuth Metadata: Causes and Fixes

July 7, 2026·22 min read·MCPForge

MCP Failed to Discover OAuth Metadata: Causes and Fixes

Quick Answer

When an MCP client reports "failed to discover OAuth metadata", it means the client could not successfully fetch and validate either the protected resource metadata (/.well-known/oauth-protected-resource) from the MCP server, the authorization server metadata (/.well-known/oauth-authorization-server) from the OAuth issuer, or both.

The most common causes are: a 404 on the metadata endpoint, HTML returned instead of JSON (often from a proxy's error page), a wrong Content-Type header, an issuer field that doesn't match the fetch URL, a TLS certificate problem, or an internal hostname leaking into production metadata.


60-Second Diagnostic Checklist

Run through this before diving into root causes. Each item can be verified in under 30 seconds.

☐ 1. Protected resource metadata returns HTTP 200 (not 301, 302, 404, or 401)
☐ 2. Protected resource metadata Content-Type is application/json
☐ 3. Protected resource metadata body is valid JSON (parse it)
☐ 4. authorization_servers array exists and contains the correct issuer URL
☐ 5. Authorization server metadata URL returns HTTP 200
☐ 6. Authorization server metadata Content-Type is application/json
☐ 7. Authorization server metadata body is valid JSON
☐ 8. issuer field in authorization server metadata EXACTLY matches the URL used to fetch it
☐ 9. TLS certificate on both endpoints is valid and not expired
☐ 10. No proxy is intercepting /.well-known/ paths and returning HTML
☐ 11. Metadata does not contain localhost or internal hostnames
☐ 12. WWW-Authenticate header on 401 response points to correct resource_metadata URL

If you can check all 12 boxes and discovery still fails, jump to Client-Specific Behavior Differences.


The MCP OAuth Discovery Flow (What Actually Happens)

Before fixing the error, you need to know exactly which HTTP requests the MCP client makes and in what order. The MCP authorization specification (based on RFC 9728 and RFC 8414) defines this sequence:

MCP Client                    MCP Server              Authorization Server
    |                              |                          |
    |-- GET /tools (no token) ---> |                          |
    |                              |                          |
    | <-- 401 + WWW-Authenticate --|                          |
    |    (resource_metadata URL)   |                          |
    |                              |                          |
    |-- GET /.well-known/          |                          |
    |   oauth-protected-resource-->|                          |
    |                              |                          |
    | <-- 200 JSON (resource meta)-|                          |
    |  (contains authorization_    |                          |
    |   servers: [issuer_url])     |                          |
    |                              |                          |
    |-- GET {issuer}/.well-known/oauth-authorization-server ->|
    |                              |                          |
    |<-- 200 JSON (auth server meta, contains auth endpoint, --|
    |    token endpoint, scopes)   |                          |
    |                              |                          |
    |-- Redirect user to auth endpoint (PKCE flow) ---------->|
    |                              |                          |
    |<-- Authorization code ----------------------------------------|
    |                              |                          |
    |-- POST token endpoint ---------------------------------->|
    |                              |                          |
    |<-- access_token -----------------------------------------|
    |                              |                          |
    |-- GET /tools + Bearer token->|                          |
    |                              |                          |
    |<-- 200 Tools list -----------|                          |

Discovery fails at one of three points:

  1. Fetching protected resource metadata from the MCP server
  2. Fetching authorization server metadata from the OAuth issuer
  3. Validating the metadata content (issuer match, JSON validity, required fields)

The error message "failed to discover OAuth metadata" can originate from any of these three steps, but the client usually doesn't tell you which one. That's why manual verification with curl is essential. For Atlassian-specific remote setups, verify the Atlassian MCP server URL before debugging OAuth metadata.


Verifying Discovery Manually

Using curl (Linux/macOS)

Step 1: Check the protected resource metadata endpoint

bash
curl -v https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  -H "Accept: application/json"

Expected output:

  • HTTP/2 200 (or HTTP/1.1 200)
  • content-type: application/json (not text/html)
  • Valid JSON body containing authorization_servers

Actual example of a correct response:

json
{
  "resource": "https://your-mcp-server.example.com",
  "authorization_servers": [
    "https://auth.example.com"
  ],
  "scopes_supported": ["tools:read", "tools:write"],
  "bearer_methods_supported": ["header"]
}

Step 2: Check the authorization server metadata endpoint

Take the first value from authorization_servers in the previous response and append /.well-known/oauth-authorization-server:

bash
curl -v https://auth.example.com/.well-known/oauth-authorization-server \
  -H "Accept: application/json"

Expected output:

  • HTTP/2 200
  • content-type: application/json
  • Valid JSON containing issuer, authorization_endpoint, token_endpoint

Step 3: Check the issuer match

The issuer field must exactly equal https://auth.example.com — no trailing slash, same scheme and host you requested from.

bash
curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .issuer
# Should output: "https://auth.example.com"

Step 4: Check the WWW-Authenticate header fallback

Some MCP clients fall back to the WWW-Authenticate header if /.well-known/oauth-protected-resource 404s. Verify what your server returns on an unauthenticated request:

bash
curl -v https://your-mcp-server.example.com/ 2>&1 | grep -i www-authenticate

A correct header looks like:

WWW-Authenticate: Bearer realm="https://auth.example.com",
  resource_metadata="https://your-mcp-server.example.com/.well-known/oauth-protected-resource"

Using PowerShell (Windows)

powershell
# Step 1: Protected resource metadata
$protectedResource = Invoke-WebRequest `
  -Uri "https://your-mcp-server.example.com/.well-known/oauth-protected-resource" `
  -Headers @{"Accept" = "application/json"} `
  -UseBasicParsing

Write-Host "Status: $($protectedResource.StatusCode)"
Write-Host "Content-Type: $($protectedResource.Headers['Content-Type'])"
$prMeta = $protectedResource.Content | ConvertFrom-Json
$prMeta | Format-List

# Step 2: Authorization server metadata
$issuerUrl = $prMeta.authorization_servers[0]
$authServerMeta = Invoke-WebRequest `
  -Uri "$issuerUrl/.well-known/oauth-authorization-server" `
  -Headers @{"Accept" = "application/json"} `
  -UseBasicParsing

$asMeta = $authServerMeta.Content | ConvertFrom-Json
Write-Host "Issuer in metadata: $($asMeta.issuer)"
Write-Host "Expected issuer:    $issuerUrl"

if ($asMeta.issuer -eq $issuerUrl) {
  Write-Host "✓ Issuer matches" -ForegroundColor Green
} else {
  Write-Host "✗ Issuer MISMATCH" -ForegroundColor Red
}

Symptom-to-Fix Reference Table

SymptomLikely CauseVerificationFix
/.well-known/oauth-protected-resource returns 404Endpoint not implemented or wrong pathcurl -I {server}/.well-known/oauth-protected-resourceImplement the endpoint or check server routing config
Response is HTML, not JSONReverse proxy intercepting with error pageCheck Content-Type headerFix proxy auth bypass for /.well-known/ paths
issuer field doesn't match fetch URLProxy rewriting Host header, or misconfigured serverCompare issuer value to URLFix proxy Host passthrough or server ISSUER_URL env var
Content-Type: text/html on 200 responseServer returning HTML at that pathcurl -v and inspect bodyFix server route to return JSON
TLS certificate errorSelf-signed cert or expired certcurl -v shows SSL errorInstall valid cert or add CA to client trust store
Redirect (301/302) to HTTPSHTTP configured but client follows to HTTPScurl -I shows Location headerUpdate server URL to use HTTPS directly
authorization_servers array is emptyResource metadata misconfiguredParse JSON responseAdd correct issuer URL to server config
Auth server metadata returns 404Wrong issuer URL in resource metadataFetch issuer /.well-known/oauth-authorization-serverFix issuer URL in resource metadata
Works locally, fails in productionLocalhost URLs in production metadataCheck issuer and resource fieldsUse production URLs in all environment configs
Works in MCP Inspector, fails in Claude DesktopHTTPS enforcement or stricter issuer validationCompare curl behavior to both clientsEnsure HTTPS, no redirects, exact issuer match
Metadata loads but auth still failsToken scope or audience mismatchCheck server token validation logsConfigure correct scopes and audience in both server and OAuth client
Stale metadata causing old config to be usedClient caching metadata across sessionsRestart client and retryAdd Cache-Control: no-store to metadata endpoints

Root Cause Deep Dives

1. Protected Resource Metadata Endpoint Returns 404

Symptom: curl -v shows HTTP/2 404 or HTTP/1.1 404 Not Found.

Why it happens: The MCP server has not implemented the /.well-known/oauth-protected-resource endpoint, or the route exists under a different path due to a routing configuration error.

How to verify:

bash
curl -I https://your-mcp-server.example.com/.well-known/oauth-protected-resource
# Look for: HTTP/2 404

# Also check the WWW-Authenticate fallback
curl -v https://your-mcp-server.example.com/mcp 2>&1 | grep -i www-authenticate

Fix: Implement the endpoint. A minimal Node.js/Express implementation:

javascript
// Express.js — protected resource metadata endpoint
app.get('/.well-known/oauth-protected-resource', (req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Cache-Control', 'no-store'); // avoid stale metadata issues
  res.json({
    resource: 'https://your-mcp-server.example.com',
    authorization_servers: ['https://auth.example.com'],
    scopes_supported: ['tools:read', 'tools:write'],
    bearer_methods_supported: ['header']
  });
});

For Python (FastAPI):

python
from fastapi import FastAPI
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/.well-known/oauth-protected-resource")
async def protected_resource_metadata():
    return JSONResponse(
        content={
            "resource": "https://your-mcp-server.example.com",
            "authorization_servers": ["https://auth.example.com"],
            "scopes_supported": ["tools:read", "tools:write"],
            "bearer_methods_supported": ["header"]
        },
        headers={"Cache-Control": "no-store"}
    )

Confirm it worked:

bash
curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource | jq .
# Should return valid JSON with authorization_servers array

2. HTML Returned Instead of JSON

This is the single most common cause in production deployments, and it's almost always a reverse proxy issue.

Symptom: The endpoint returns HTTP 200 but Content-Type: text/html, and the body is an HTML login page or error page. The MCP client fails to parse it as JSON.

Why it happens: Your reverse proxy (nginx, Caddy, AWS ALB, Cloudflare) requires authentication for all routes, including /.well-known/. When the MCP client (unauthenticated at this point) requests the metadata URL, the proxy returns its own 401 HTML page instead of passing the request through to your MCP server.

How to verify:

bash
curl -v https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  2>&1 | grep -E '(HTTP|content-type|<!DOCTYPE|<html)'

If you see content-type: text/html or <!DOCTYPE html>, you have this problem.

Fix for nginx:

nginx
server {
    listen 443 ssl;
    server_name your-mcp-server.example.com;

    # Allow metadata endpoints without authentication
    location /.well-known/ {
        proxy_pass http://mcp-backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Do NOT include auth_request or auth_basic here
    }

    # All other routes require auth
    location / {
        auth_request /auth;
        proxy_pass http://mcp-backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Fix for AWS ALB: Add a listener rule with higher priority that forwards /.well-known/* paths directly to your MCP target group without authentication actions.

Fix for Cloudflare Access: Create a bypass rule for the /.well-known/ path in your Cloudflare Access policy.

Confirm it worked:

bash
curl -s -o /dev/null -w "%{content_type}" \
  https://your-mcp-server.example.com/.well-known/oauth-protected-resource
# Should output: application/json

3. Issuer Mismatch

This is the most subtle and frustrating failure mode because discovery appears to work — both metadata documents load — but the client then aborts with a validation error.

Symptom: Both metadata endpoints return 200 and valid JSON, but the client still reports a discovery failure or authentication failure.

Why it happens: RFC 8414, Section 3.3 requires that the issuer field in authorization server metadata exactly match the URL used to retrieve it. Concretely: if you fetched from https://auth.example.com/.well-known/oauth-authorization-server, the response MUST contain "issuer": "https://auth.example.com". No trailing slash. Same scheme. Same host.

Common triggers:

  • A proxy rewrites the Host header, so the auth server generates issuer: https://auth.internal instead of https://auth.example.com
  • The OAuth server is configured with a development URL (http://localhost:8080) deployed to production
  • The issuer has a trailing slash (https://auth.example.com/) while the URL doesn't

How to verify:

bash
ISSUER_URL=$(curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  | jq -r '.authorization_servers[0]')

echo "Expected issuer: $ISSUER_URL"

ACTUAL_ISSUER=$(curl -s "$ISSUER_URL/.well-known/oauth-authorization-server" \
  | jq -r '.issuer')

echo "Actual issuer:   $ACTUAL_ISSUER"

if [ "$ISSUER_URL" = "$ACTUAL_ISSUER" ]; then
  echo "✓ Match"
else
  echo "✗ MISMATCH — this will break authentication"
fi

Fix: Set the correct public-facing issuer URL in your OAuth server configuration. For common servers:

Keycloak:

bash
# In keycloak.conf or environment:
KC_HOSTNAME=https://auth.example.com
KC_HOSTNAME_STRICT=true

Auth0: The issuer is fixed as your Auth0 tenant URL. Make sure authorization_servers in your resource metadata points to exactly https://your-tenant.auth0.com (no trailing slash).

custom OAuth server (Node.js with node-oidc-provider):

javascript
const provider = new Provider('https://auth.example.com', { // This IS the issuer
  // ...
});

For nginx proxying to an internal auth server:

nginx
location /.well-known/oauth-authorization-server {
    proxy_pass http://auth-internal:8080;
    proxy_set_header Host auth.example.com; # Force the public hostname
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host auth.example.com;
}

Confirm it worked: Re-run the verification script above. Both values must be identical strings.


4. Wrong Content-Type Header

Symptom: curl -v shows the endpoint returns JSON content but with Content-Type: text/plain or Content-Type: application/octet-stream.

Why it happens: Static file servers serving metadata as static files often don't associate application/json with the .json extension or with extensionless well-known paths. Some frameworks default to text/plain for route handlers that don't explicitly set headers.

How to verify:

bash
curl -sI https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  | grep -i content-type

Fix (nginx — static file serving):

nginx
location = /.well-known/oauth-protected-resource {
    alias /var/www/well-known/oauth-protected-resource.json;
    default_type application/json;
    add_header Cache-Control "no-store";
}

Fix (Express.js): Explicitly set the header:

javascript
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(JSON.stringify(metadata));
// Or use res.json() which sets Content-Type automatically

Confirm it worked:

bash
curl -sI https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  | grep -i content-type
# Should show: content-type: application/json

5. Redirects Breaking Discovery

Symptom: curl -v shows a 301 Moved Permanently or 302 Found before the metadata response. The final destination may return correct JSON, but some MCP clients don't follow redirects for metadata fetches.

Why it happens: The most common case is HTTP→HTTPS redirect. The MCP server URL in the client configuration uses http://, which redirects to https://. Another case is trailing-slash redirects (/.well-known/oauth-protected-resource//.well-known/oauth-protected-resource).

How to verify:

bash
# Do NOT use -L (follow redirects) first — see what really happens
curl -v https://your-mcp-server.example.com/.well-known/oauth-protected-resource 2>&1 \
  | grep -E '(HTTP/|Location:)'

Fix: Update the MCP server URL in the client configuration to use the final destination URL directly (HTTPS, no trailing slash). Do not rely on redirects for well-known discovery endpoints. The MCP specification does not require clients to follow redirects during discovery.


6. TLS Certificate Problems

Symptom: curl reports SSL certificate problem: unable to get local issuer certificate or SSL: certificate subject name does not match target host name.

How to verify:

bash
curl -v https://your-mcp-server.example.com/.well-known/oauth-protected-resource 2>&1 \
  | grep -E '(SSL|TLS|certificate|verify)'

# Also check expiry:
echo | openssl s_client -connect your-mcp-server.example.com:443 -servername your-mcp-server.example.com 2>/dev/null \
  | openssl x509 -noout -dates

Common TLS issues:

IssueVerificationFix
Self-signed certificatecurl fails without -k flagInstall a CA-signed cert (Let's Encrypt works)
Expired certificateopenssl x509 -noout -dates shows past dateRenew the certificate
CN/SAN mismatchOpenSSL shows different common nameReissue cert for correct hostname
Missing intermediate CAcurl fails, browser shows chain errorInclude full chain in server cert file
Internal-only CAWorks inside network, fails outsideAdd CA to client trust store or use public CA

PowerShell TLS check:

powershell
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
try {
    $response = Invoke-WebRequest `
        -Uri "https://your-mcp-server.example.com/.well-known/oauth-protected-resource"
    Write-Host "TLS OK, Status: $($response.StatusCode)"
} catch {
    Write-Host "TLS Error: $($_.Exception.Message)" -ForegroundColor Red
}

7. Localhost or Internal URLs in Production Metadata

This is the single most common reason "works locally, fails in production" — and it's easy to miss because the error message doesn't tell you the issuer URL it tried to reach.

Symptom: Discovery works perfectly in your local dev environment. After deploying to production, it fails. Your server logs may show no incoming requests for the metadata endpoint.

Why it happens: Your OAuth server's ISSUER environment variable is set to http://localhost:8080 or http://auth-service:8080 (an internal Docker network hostname). The MCP server's resource metadata correctly lists this as the authorization_servers entry. In production, when an MCP client outside your network tries to fetch http://localhost:8080/.well-known/oauth-authorization-server, it obviously fails — it's hitting its own localhost.

How to verify:

bash
curl -s https://your-production-mcp-server.example.com/.well-known/oauth-protected-resource \
  | jq '.authorization_servers'
# Red flag if output is:
# ["http://localhost:8080"]
# or
# ["http://auth-service:8080"]

Fix: Use environment-specific configuration. Never hardcode local URLs in metadata that gets served to external clients.

bash
# Production environment variables
MCP_SERVER_URL=https://mcp.example.com
OAUTH_ISSUER_URL=https://auth.example.com
javascript
// resource metadata — always read from environment
app.get('/.well-known/oauth-protected-resource', (req, res) => {
  res.json({
    resource: process.env.MCP_SERVER_URL,
    authorization_servers: [process.env.OAUTH_ISSUER_URL],
    scopes_supported: ['tools:read', 'tools:write'],
    bearer_methods_supported: ['header']
  });
});

Confirm it worked: After deploying with corrected environment variables, re-run:

bash
curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
  | jq '.authorization_servers[0]'
# Should be a publicly reachable HTTPS URL

8. Invalid JSON in Metadata Response

Symptom: The endpoint returns 200 with Content-Type: application/json, but the body is malformed JSON — trailing commas, comments, truncated response, or a JSON parse error.

How to verify:

bash
curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource | python3 -m json.tool
# If invalid: "json.decoder.JSONDecodeError: ..."
# If valid: Pretty-printed JSON output

Or with jq:

bash
curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource | jq empty
# Exits 0 if valid, non-zero with error message if invalid
echo "Exit code: $?"

Common causes: Manually edited static JSON files with trailing commas (not valid JSON), server-side templating that injects non-JSON characters, chunked transfer encoding issues causing truncation.

Fix: Validate JSON after every manual edit with jq empty filename.json. Use your server framework's JSON serialization rather than manual string construction.


9. Stale Cached Metadata

Symptom: You've fixed the metadata endpoint, verified it with curl, but the MCP client still shows the old error.

Why it happens: MCP clients may cache OAuth metadata for the lifetime of a session or persist it to disk between sessions. This is per-client behavior, not defined by the spec.

Fix (server-side): Set Cache-Control: no-store on both metadata endpoints. This doesn't fix already-cached values but prevents future caching:

javascript
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');

Fix (client-side):

  • Claude Desktop: Restart the application completely. On macOS, also check ~/Library/Application Support/Claude/ for cached OAuth state.
  • Cursor: Restart the window or use Command Palette → "Reload Window"
  • MCP Inspector: Refresh the browser tab or clear localStorage

To make debugging faster, configure your server to set Cache-Control: no-store on metadata endpoints during development so you never fight stale caches.


Reverse Proxy Troubleshooting Deep Dive

Reverse proxies are responsible for the majority of production MCP OAuth discovery failures. Here's a systematic approach.

Identify Which Layer Is Failing

Test from each network layer:

bash
# 1. Direct to the MCP server process (bypassing proxy)
curl -v http://localhost:3000/.well-known/oauth-protected-resource

# 2. Through the reverse proxy (internal network)
curl -v http://your-internal-lb/.well-known/oauth-protected-resource \
  -H "Host: your-mcp-server.example.com"

# 3. From outside (public internet path)
curl -v https://your-mcp-server.example.com/.well-known/oauth-protected-resource

If step 1 works but step 3 fails, the proxy is the problem.

nginx Proxy Configuration for MCP OAuth

A complete, production-ready nginx configuration that handles MCP OAuth discovery correctly:

nginx
server {
    listen 443 ssl http2;
    server_name your-mcp-server.example.com;

    ssl_certificate /etc/letsencrypt/live/your-mcp-server.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-mcp-server.example.com/privkey.pem;

    # CRITICAL: Allow well-known endpoints without any auth middleware
    location /.well-known/ {
        proxy_pass http://mcp-app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # Don't cache metadata in nginx
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        
        # Ensure JSON content type passes through
        proxy_pass_header Content-Type;
    }

    location / {
        proxy_pass http://mcp-app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Redirect HTTP to HTTPS (but clients should use HTTPS directly)
server {
    listen 80;
    server_name your-mcp-server.example.com;
    return 301 https://$host$request_uri;
}

Caddy Configuration

caddy
your-mcp-server.example.com {
    # Caddy handles TLS automatically
    
    # Well-known endpoints — forward without auth
    handle /.well-known/* {
        reverse_proxy mcp-app:3000
    }
    
    # All other routes
    handle {
        reverse_proxy mcp-app:3000
    }
}

Diagnosing Host Header Rewriting

If your OAuth server generates the issuer from the incoming Host header, a misconfigured proxy can silently break it:

bash
# Check what Host header reaches your auth server
curl -v https://auth.example.com/.well-known/oauth-authorization-server 2>&1 \
  | grep -E '(> Host:|< issuer)'

# Add server-side debug logging to echo back received headers
# (temporary, remove in production)
curl -s "https://your-debug-endpoint.example.com/echo-headers" | jq .

Client-Specific Behavior Differences

Some discovery issues appear in one MCP client but not another because clients implement the specification with different strictness levels.

Claude Desktop

  • Enforces HTTPS for all metadata fetches in production
  • Does not follow HTTP redirects during discovery
  • Validates issuer match strictly
  • Caches OAuth metadata per server URL; restart required after config changes

Cursor

  • Generally follows the same strictness as Claude Desktop for hosted MCPs
  • Local MCP servers using stdio transport bypass OAuth entirely — OAuth only applies to HTTP/SSE transport

MCP Inspector

  • More permissive for development purposes; may follow redirects
  • Useful for isolating whether the issue is server-side or client-side
  • Use MCP Inspector's OAuth flow visualization to trace each step of discovery interactively

Diagnosis when behavior differs between clients

bash
# Simulate strict client behavior with curl
# No redirect following, explicit HTTPS, validate response
curl \
  --no-location \
  --tlsv1.2 \
  --fail \
  -H "Accept: application/json" \
  -w "\nHTTP Status: %{http_code}\nContent-Type: %{content_type}" \
  https://your-mcp-server.example.com/.well-known/oauth-protected-resource

If this curl command fails and curl -L (with redirect following) succeeds, you've identified that a strict client will fail while a permissive one will succeed.


Metadata Discovery Succeeds but Authentication Still Fails

This is worth addressing separately because the error message can be misleading — you might still see "failed to discover OAuth metadata" in some clients even when the actual problem is post-discovery.

Token Scope Mismatch

The MCP server requires specific scopes (tools:read, tools:write) but the OAuth client registration doesn't include them, or the user declined to grant them.

How to verify: Decode the access token (it's a JWT in most cases) and check the scp or scope claim:

bash
# Decode JWT without verification (for debugging only)
TOKEN="eyJ..."
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .scope

Fix: Add the required scopes to your OAuth client registration and update the authorization request.

Audience (aud) Claim Mismatch

The MCP server validates that the access token's aud claim includes its own identifier. Tokens issued for a different resource will be rejected.

bash
# Check the aud claim in the token
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .aud
# Should match the MCP server's resource identifier

Fix: Configure your OAuth server to include the MCP server's resource URI in the token's aud claim when that resource is requested. This is the Resource Indicators extension (RFC 8707).

Bearer Token Not Sent

Occasionally the client discovers metadata and acquires a token but fails to attach it to subsequent MCP requests.

bash
# Test manually with a token
curl -v https://your-mcp-server.example.com/mcp \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

If this returns 200 but the MCP client returns 401, the client isn't sending the token correctly.

For a deeper look at enterprise-level authentication setup in MCP, the enterprise managed authorization guide covers OAuth configuration patterns for organizational deployments.


Minimal Reproduction Steps

When debugging is taking too long or you need to file a bug report, create a minimal reproduction:

1. Minimal MCP server with OAuth metadata

javascript
// minimal-mcp-oauth-test.js
// Run: node minimal-mcp-oauth-test.js
// Then: curl http://localhost:3001/.well-known/oauth-protected-resource

const http = require('http');

const RESOURCE_METADATA = {
  resource: 'http://localhost:3001',
  authorization_servers: ['https://your-real-auth-server.example.com'],
  scopes_supported: ['tools:read'],
  bearer_methods_supported: ['header']
};

const server = http.createServer((req, res) => {
  if (req.url === '/.well-known/oauth-protected-resource') {
    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Cache-Control': 'no-store'
    });
    res.end(JSON.stringify(RESOURCE_METADATA, null, 2));
    return;
  }

  const authHeader = req.headers['authorization'];
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.writeHead(401, {
      'WWW-Authenticate':
        'Bearer realm="http://localhost:3001",' +
        ' resource_metadata="http://localhost:3001/.well-known/oauth-protected-resource"'
    });
    res.end(JSON.stringify({ error: 'unauthorized' }));
    return;
  }

  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ result: 'authenticated' }));
});

server.listen(3001, () => console.log('Test server on http://localhost:3001'));

This isolates your MCP server from OAuth complexity. If discovery works against this minimal server but not your real server, the issue is in your real server's implementation.

2. Verify the auth server directly

bash
#!/bin/bash
# oauth-discovery-verify.sh
# Usage: ./oauth-discovery-verify.sh https://your-mcp-server.example.com

MCP_SERVER=${1:-"https://your-mcp-server.example.com"}
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

echo "=== MCP OAuth Discovery Verification ==="
echo "MCP Server: $MCP_SERVER"
echo ""

# Step 1: Protected Resource Metadata
echo "[1/4] Fetching protected resource metadata..."
PR_RESPONSE=$(curl -sS -w "\n%{http_code}\n%{content_type}" \
  "$MCP_SERVER/.well-known/oauth-protected-resource" \
  -H "Accept: application/json" 2>&1)

PR_BODY=$(echo "$PR_RESPONSE" | head -n -2)
PR_STATUS=$(echo "$PR_RESPONSE" | tail -n 2 | head -n 1)
PR_CT=$(echo "$PR_RESPONSE" | tail -n 1)

if [ "$PR_STATUS" = "200" ]; then
  echo -e "  ${GREEN}✓${NC} Status: $PR_STATUS"
else
  echo -e "  ${RED}✗${NC} Status: $PR_STATUS (expected 200)"
fi

if echo "$PR_CT" | grep -q "application/json"; then
  echo -e "  ${GREEN}✓${NC} Content-Type: $PR_CT"
else
  echo -e "  ${RED}✗${NC} Content-Type: $PR_CT (expected application/json)"
fi

ISSUER_URL=$(echo "$PR_BODY" | jq -r '.authorization_servers[0]' 2>/dev/null)
if [ -n "$ISSUER_URL" ] && [ "$ISSUER_URL" != "null" ]; then
  echo -e "  ${GREEN}✓${NC} authorization_servers[0]: $ISSUER_URL"
else
  echo -e "  ${RED}✗${NC} authorization_servers array missing or empty"
  exit 1
fi

# Step 2: Authorization Server Metadata
echo ""
echo "[2/4] Fetching authorization server metadata from $ISSUER_URL..."
AS_RESPONSE=$(curl -sS -w "\n%{http_code}\n%{content_type}" \
  "$ISSUER_URL/.well-known/oauth-authorization-server" \
  -H "Accept: application/json" 2>&1)

AS_BODY=$(echo "$AS_RESPONSE" | head -n -2)
AS_STATUS=$(echo "$AS_RESPONSE" | tail -n 2 | head -n 1)

if [ "$AS_STATUS" = "200" ]; then
  echo -e "  ${GREEN}✓${NC} Status: $AS_STATUS"
else
  echo -e "  ${RED}✗${NC} Status: $AS_STATUS"
fi

# Step 3: Issuer match
echo ""
echo "[3/4] Validating issuer match..."
ACTUAL_ISSUER=$(echo "$AS_BODY" | jq -r '.issuer' 2>/dev/null)
echo "  Expected: $ISSUER_URL"
echo "  Actual:   $ACTUAL_ISSUER"

if [ "$ISSUER_URL" = "$ACTUAL_ISSUER" ]; then
  echo -e "  ${GREEN}✓${NC} Issuer matches"
else
  echo -e "  ${RED}✗${NC} Issuer MISMATCH"
fi

# Step 4: Required fields
echo ""
echo "[4/4] Checking required authorization server fields..."
for field in authorization_endpoint token_endpoint; do
  VALUE=$(echo "$AS_BODY" | jq -r ".\"$field\"" 2>/dev/null)
  if [ -n "$VALUE" ] && [ "$VALUE" != "null" ]; then
    echo -e "  ${GREEN}✓${NC} $field: $VALUE"
  else
    echo -e "  ${RED}✗${NC} $field: MISSING"
  fi
done

echo ""
echo "=== Verification complete ==="

Make it executable and run:

bash
chmod +x oauth-discovery-verify.sh
./oauth-discovery-verify.sh https://your-mcp-server.example.com

Bug Report Checklist

If you've exhausted local debugging and need to report the issue to an MCP server vendor or open a GitHub issue, include all of this:

## Environment
- MCP client: [Claude Desktop / Cursor / MCP Inspector / other]
- MCP client version: 
- OS and version:
- Network: [direct / behind corporate proxy / VPN]

## MCP Server
- Server software and version:
- Transport: [stdio / HTTP+SSE / Streamable HTTP]
- Deployed at: [URL or "local"]

## Discovery Verification Results
Paste output of the oauth-discovery-verify.sh script or equivalent curl commands.

## Exact Error Message
[Paste the exact error from the MCP client]

## What You've Already Tried
[List each fix attempted]

## curl output for protected resource metadata endpoint

curl -v {your-mcp-server}/.well-known/oauth-protected-resource

[Paste full output including headers]

## curl output for authorization server metadata endpoint

curl -v {issuer-url}/.well-known/oauth-authorization-server

[Paste full output including headers]

## Does it work in MCP Inspector?
[Yes / No / Not tested]

## Does it work with curl -L (redirect following)?
[Yes / No]

For connection issues beyond OAuth (network timeouts, transport failures), the MCP failed to connect troubleshooting guide covers those scenarios separately.


Production Security Notes

While fixing discovery, don't inadvertently introduce security problems:

Do not disable TLS verification in production clients. If you're tempted to add --insecure flags or set NODE_TLS_REJECT_UNAUTHORIZED=0 to bypass cert errors — fix the cert instead. These settings disable security, not just discovery errors.

Do not make your entire /.well-known/ directory publicly writable. Allowing OAuth metadata to be modified by attackers is equivalent to a full authentication bypass. Metadata endpoints should be read-only, generated from server-controlled configuration.

Validate redirect URIs strictly. If your OAuth server allows wildcard redirect URIs during testing, lock them down before production. An open redirector can be exploited to capture authorization codes.

Use Cache-Control: no-store during development, but consider appropriate caching in production. Authorization server metadata changes rarely. Once stable, caching it with a moderate TTL (Cache-Control: max-age=3600) reduces latency for users without causing stale-config problems.

For a comprehensive look at securing MCP deployments, the MCP security best practices guide covers token validation, scope enforcement, and audit logging in detail.

You can also use MCPForge Verify to run automated checks against your MCP server's OAuth configuration and catch discovery problems before your users do.


Official Sources

Want to analyze your API security?

Import your OpenAPI spec and generate a Security Report automatically.

Frequently Asked Questions

Q: Which URL does an MCP client hit first during OAuth discovery?

The MCP client first requests {mcp-server-origin}/.well-known/oauth-protected-resource. From the authorization_servers array in that response, it constructs {issuer}/.well-known/oauth-authorization-server and fetches authorization server metadata. If the protected resource metadata endpoint returns 404, some clients fall back to reading the resource_metadata parameter from the WWW-Authenticate: Bearer header on a 401 response.

Q: Do all MCP servers need to implement /.well-known/oauth-protected-resource?

Only MCP servers that use OAuth-protected HTTP transport. Servers using stdio transport don't use OAuth at all — authentication is handled at the process level. HTTP+SSE and Streamable HTTP servers that require authentication must implement the protected resource metadata endpoint for compliant MCP clients to discover their OAuth configuration.

Q: What if my OAuth server is OpenID Connect, not plain OAuth?

OpenID Connect servers expose metadata at /.well-known/openid-configuration in addition to (or instead of) /.well-known/oauth-authorization-server. MCP clients that follow the specification check both paths. Ensure your authorization_servers entry is the OIDC issuer URL and that the discovery document at {issuer}/.well-known/openid-configuration contains authorization_endpoint and token_endpoint. Auth0, Okta, and Keycloak all expose proper OIDC discovery documents.

Q: Why does the error say "failed to discover" when the metadata endpoint is reachable?

MCP clients validate the metadata content after fetching it, not just whether the fetch succeeded. A 200 response with HTML content, invalid JSON, a missing issuer field, or an issuer mismatch will all cause the client to report a discovery failure even though the HTTP request technically succeeded.

Q: My corporate proxy requires authentication for all outbound requests. How do I handle this for MCP OAuth discovery?

This is a real enterprise challenge. The MCP client needs to fetch metadata from both your MCP server and your OAuth authorization server. If the corporate proxy requires authentication for those requests, you need to configure the MCP client with proxy credentials (most clients respect HTTPS_PROXY and NO_PROXY environment variables) or whitelist those specific domains in the proxy. The proxy cannot intercept and require auth for /.well-known/ paths — doing so breaks the discovery chain.

Q: Is there a way to skip discovery and hardcode OAuth configuration in the MCP client?

The MCP specification requires clients to use metadata discovery. Clients that support hardcoded configuration bypass the spec requirement, but most production clients (Claude Desktop, Cursor) don't expose this option. The correct approach is to fix discovery rather than work around it.

Frequently Asked Questions

Which URL does an MCP client hit first during OAuth discovery?

The MCP client first sends a GET request to the MCP server's protected resource metadata URL, typically `{server-origin}/.well-known/oauth-protected-resource`. From there it reads the `authorization_servers` array and fetches authorization server metadata from the issuer's `/.well-known/oauth-authorization-server` (or OpenID Connect's `/.well-known/openid-configuration`). If the resource metadata endpoint 404s, some clients fall back to reading the `WWW-Authenticate` header returned by the MCP server on a 401 response.

Why does OAuth metadata discovery work in MCP Inspector but fail in Claude Desktop?

The most common reasons are: (1) Claude Desktop enforces HTTPS and will not follow HTTP→HTTPS redirects during metadata fetch, whereas MCP Inspector may be more permissive; (2) Claude Desktop validates that the `issuer` field in authorization server metadata exactly matches the base URL it fetched from, while Inspector may not; (3) Claude Desktop respects proxy settings differently. Verify each step with curl using the same network path the client uses.

What is the difference between protected resource metadata and authorization server metadata in MCP?

Protected resource metadata (RFC 9728 / `/.well-known/oauth-protected-resource`) describes the MCP server itself — what scopes it requires and which authorization servers can issue tokens for it. Authorization server metadata (RFC 8414 / `/.well-known/oauth-authorization-server`) describes the OAuth server — its authorization endpoint, token endpoint, supported grant types, etc. An MCP client needs both. A failure in either causes the 'failed to discover OAuth metadata' error.

Can a reverse proxy cause MCP OAuth metadata discovery to fail?

Yes, and this is one of the most common production causes. Proxies that strip the `Host` header, rewrite URLs in JSON response bodies, return 401 HTML error pages instead of passing through JSON, or terminate TLS without correctly forwarding the original scheme will all break discovery. Check every hop with curl -v and compare the response body and headers at each stage.

Why does the issuer URL mismatch break authentication even when metadata loads?

RFC 8414 requires that the `issuer` field in authorization server metadata exactly match the URL the client used to fetch it (minus the `/.well-known/...` suffix). If a proxy rewrites the host or scheme, the fetched JSON will contain an issuer like `https://auth.internal` while the client expected `https://auth.example.com`. Most strict clients abort authentication when they detect this mismatch, even though the metadata document loaded successfully.

What HTTP status code should the metadata endpoints return?

Both `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` must return HTTP 200 with `Content-Type: application/json`. Any other status (301, 302, 401, 403, 404, 500) or a non-JSON Content-Type will cause the MCP client to treat discovery as failed. Some clients also reject responses with a JSON body that contains an `error` field at the top level.

How do I test OAuth metadata discovery without an MCP client?

Use curl: `curl -v https://your-mcp-server/.well-known/oauth-protected-resource` and `curl -v https://your-auth-server/.well-known/oauth-authorization-server`. Check that both return HTTP 200, Content-Type: application/json, valid JSON, and that the `issuer` value in authorization server metadata matches the base URL you used. MCP Inspector's OAuth tab also visualizes the full discovery chain without needing Claude Desktop.

What does 'stale cached metadata' mean and how do I fix it?

Some MCP clients cache OAuth metadata (protected resource or authorization server) for the duration of a session or longer. If you update your server configuration — changing the issuer URL, rotating keys, or adding scopes — but the client is reading cached metadata, it will use outdated values. Fix it by restarting the MCP client, clearing its config cache, or (server-side) setting `Cache-Control: no-store` on metadata endpoint responses during development.

My metadata endpoints return correct JSON locally but fail in production. What should I check first?

Check three things in order: (1) Is the production server accessible over HTTPS with a valid TLS certificate? Run `curl -v https://your-production-server/.well-known/oauth-protected-resource` from outside your network. (2) Does the `issuer` field contain the production URL, not localhost or an internal hostname? (3) Does the production reverse proxy pass the correct `Host` header and not intercept the `/.well-known/` path with its own 401 challenge?

Authentication succeeds but tool calls still fail after OAuth metadata discovery. Why?

Discovery and authentication are separate phases. If discovery and token acquisition succeed but tool calls fail, the likely causes are: (1) the access token is missing required scopes for specific tools; (2) the MCP server validates the token's `aud` (audience) claim and rejects tokens not specifically issued for it; (3) the client is not sending the `Authorization: Bearer` header on subsequent requests. Check the server-side token validation logs, not just the discovery flow.

Check your MCP security posture

Generate a Security Score, detect risky tools, and review permissions before exposing APIs to AI agents.