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:
- Fetching protected resource metadata from the MCP server
- Fetching authorization server metadata from the OAuth issuer
- 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
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(nottext/html)- Valid JSON body containing
authorization_servers
Actual example of a correct response:
{
"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:
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.
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:
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)
# 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
| Symptom | Likely Cause | Verification | Fix |
|---|---|---|---|
/.well-known/oauth-protected-resource returns 404 | Endpoint not implemented or wrong path | curl -I {server}/.well-known/oauth-protected-resource | Implement the endpoint or check server routing config |
| Response is HTML, not JSON | Reverse proxy intercepting with error page | Check Content-Type header | Fix proxy auth bypass for /.well-known/ paths |
issuer field doesn't match fetch URL | Proxy rewriting Host header, or misconfigured server | Compare issuer value to URL | Fix proxy Host passthrough or server ISSUER_URL env var |
Content-Type: text/html on 200 response | Server returning HTML at that path | curl -v and inspect body | Fix server route to return JSON |
| TLS certificate error | Self-signed cert or expired cert | curl -v shows SSL error | Install valid cert or add CA to client trust store |
| Redirect (301/302) to HTTPS | HTTP configured but client follows to HTTPS | curl -I shows Location header | Update server URL to use HTTPS directly |
authorization_servers array is empty | Resource metadata misconfigured | Parse JSON response | Add correct issuer URL to server config |
| Auth server metadata returns 404 | Wrong issuer URL in resource metadata | Fetch issuer /.well-known/oauth-authorization-server | Fix issuer URL in resource metadata |
| Works locally, fails in production | Localhost URLs in production metadata | Check issuer and resource fields | Use production URLs in all environment configs |
| Works in MCP Inspector, fails in Claude Desktop | HTTPS enforcement or stricter issuer validation | Compare curl behavior to both clients | Ensure HTTPS, no redirects, exact issuer match |
| Metadata loads but auth still fails | Token scope or audience mismatch | Check server token validation logs | Configure correct scopes and audience in both server and OAuth client |
| Stale metadata causing old config to be used | Client caching metadata across sessions | Restart client and retry | Add 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:
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:
// 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):
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:
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:
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:
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:
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
Hostheader, so the auth server generatesissuer: https://auth.internalinstead ofhttps://auth.example.com - The OAuth server is configured with a development URL (
http://localhost:8080) deployed to production - The
issuerhas a trailing slash (https://auth.example.com/) while the URL doesn't
How to verify:
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:
# 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):
const provider = new Provider('https://auth.example.com', { // This IS the issuer
// ...
});
For nginx proxying to an internal auth server:
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:
curl -sI https://your-mcp-server.example.com/.well-known/oauth-protected-resource \
| grep -i content-type
Fix (nginx — static file serving):
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:
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:
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:
# 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:
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:
| Issue | Verification | Fix |
|---|---|---|
| Self-signed certificate | curl fails without -k flag | Install a CA-signed cert (Let's Encrypt works) |
| Expired certificate | openssl x509 -noout -dates shows past date | Renew the certificate |
| CN/SAN mismatch | OpenSSL shows different common name | Reissue cert for correct hostname |
| Missing intermediate CA | curl fails, browser shows chain error | Include full chain in server cert file |
| Internal-only CA | Works inside network, fails outside | Add CA to client trust store or use public CA |
PowerShell TLS check:
[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:
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.
# Production environment variables
MCP_SERVER_URL=https://mcp.example.com
OAUTH_ISSUER_URL=https://auth.example.com
// 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:
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:
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:
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:
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:
# 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:
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
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:
# 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
stdiotransport 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
# 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:
# 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.
# 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.
# 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
// 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
#!/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:
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
- MCP authorization specification - official discovery requirements for protected resource metadata, authorization server metadata, and OAuth-based MCP transports.
- OAuth 2.0 Authorization Server Metadata - the RFC behind the
.well-known/oauth-authorization-serverdiscovery document.
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.