I had a hard time making an MCP server for the next version of Oncalls.com
Took me a while to figure out what was wrong with versions 0.1, 0.2 and 0.3 ..
So ..
A Complete Guide to Implementing OAuth for MCP Servers with Claude Desktop
A comprehensive, battle-tested guide to implementing OAuth 2.0 authentication for Model Context Protocol (MCP) servers that work seamlessly with Claude Desktop’s native UI.
Table of Contents#
Overview#
This guide documents the complete requirements for implementing an OAuth-authenticated MCP server that works with Claude Desktop’s native “Add Connector” UI. The official MCP documentation covers the basics but omits critical implementation details that we discovered through extensive testing.
What This Guide Covers#
Remote MCP server deployment (not local stdio)
OAuth 2.0 Authorization Code flow with PKCE
Claude Desktop native UI integration (not
mcp-remotebridge)Dynamic Client Registration (RFC 7591)
Key Insight#
Claude Desktop routes OAuth through claude.ai’s backend. When you add a remote MCP server via the UI, Claude.ai acts as the OAuth client on your behalf. This has significant implications:
Claude.ai must be able to register itself dynamically with your authorization server
Claude.ai is a “public client” and cannot use pre-shared secrets
Your authorization server must support PKCE for security
Architecture#
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude Desktop │────▶│ claude.ai │────▶│ Your MCP Server│
│ (User) │ │ (OAuth Client) │ │ (Resource) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Your OAuth/ │◀────│ Protected │
│ Auth Server │ │ Resource Meta │
└─────────────────┘ └─────────────────┘
Components#
MCP Server - Your server implementing MCP protocol (tools, resources, prompts)
Authorization Server - OAuth 2.0 server (can be same or separate from MCP server)
Claude.ai Backend - Acts as OAuth client, handles token storage
Claude Desktop - User interface, communicates with claude.ai
Transport Layer Requirements#
Your MCP server must support HTTP-based transports. Claude.ai uses the newer Streamable HTTP transport by default.
Required: Streamable HTTP Transport (Protocol 2025-11-25)#
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
// Single endpoint handles GET, POST, DELETE app.all('/mcp', authMiddleware, async (req, res) => { const sessionId = req.headers['mcp-session-id'];
if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { // New session initialization const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { activeTransports.set(newSessionId, transport); }, });
const mcpServer = createMcpServer(authenticatedClient);
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
} else if (sessionId && activeTransports.has(sessionId)) { // Existing session const transport = activeTransports.get(sessionId); await transport.handleRequest(req, res, req.body); } });
Recommended: Also Support SSE Transport (Protocol 2024-11-05)#
For backwards compatibility with mcp-remote and other clients:
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
// SSE endpoint for establishing connection app.get('/sse', authMiddleware, async (req, res) => { const transport = new SSEServerTransport('/message', res); activeTransports.set(transport.sessionId, transport);
const mcpServer = createMcpServer(authenticatedClient); await mcpServer.connect(transport); });
// Message endpoint for SSE transport app.post('/message', async (req, res) => { const sessionId = req.query.sessionId; const transport = activeTransports.get(sessionId); await transport.handlePostMessage(req, res, req.body); });
Critical: Support Both on /sse Endpoint#
Some clients (like mcp-remote) use “http-first” strategy - they POST to /sse expecting Streamable HTTP, and fall back to SSE GET if that fails. Your /sse endpoint should handle both:
app.post('/sse', authMiddleware, async (req, res) => {
const sessionId = req.query.sessionId;
if (!sessionId) { // No sessionId in query = Streamable HTTP request // Handle as Streamable HTTP initialization if (isInitializeRequest(req.body)) { const transport = new StreamableHTTPServerTransport({...}); // ... create session await transport.handleRequest(req, res, req.body); } } else { // sessionId in query = SSE transport message const transport = activeTransports.get(sessionId); await transport.handlePostMessage(req, res, req.body); } });
OAuth Discovery Endpoints#
1. Protected Resource Metadata (RFC 9728)#
Endpoint: /.well-known/oauth-protected-resource
This is the entry point for OAuth discovery. When your MCP server returns a 401, the WWW-Authenticate header points to this endpoint.
app.get('/.well-known/oauth-protected-resource', (req, res) => {
res.json({
resource: 'https://your-mcp-server.com',
authorization_servers: ['https://your-auth-server.com'], // Array of issuer URLs
scopes_supported: ['read:data', 'write:data'],
bearer_methods_supported: ['header'],
});
});
Critical Details:
authorization_serversmust be an array of strings (issuer URLs), not objectsThe issuer URL should NOT include paths - just the base URL
Claude.ai will fetch
{issuer}/.well-known/oauth-authorization-server
2. 401 Response with WWW-Authenticate Header#
When an unauthenticated request hits your MCP endpoint:
app.get('/sse', (req, res, next) => {
if (!isAuthenticated(req)) {
res.setHeader(
'WWW-Authenticate',
Bearer resource_metadata="https://your-mcp-server.com/.well-known/oauth-protected-resource"
);
res.status(401).json({
error: 'unauthorized',
error_description: 'Authentication required'
});
return;
}
next();
});
3. Handle Path Variations#
Some clients append the resource path to the metadata URL. Handle these variations:
// Primary endpoint
app.get('/.well-known/oauth-protected-resource', protectedResourceHandler);
// Some clients append /sse or /mcp app.get('/.well-known/oauth-protected-resource/sse', protectedResourceHandler); app.get('/.well-known/oauth-protected-resource/mcp', protectedResourceHandler);
Authorization Server Implementation#
Authorization Server Metadata (RFC 8414)#
Endpoint: /.well-known/oauth-authorization-server
app.get('/.well-known/oauth-authorization-server', (req, res) => {
res.json({
issuer: 'https://your-auth-server.com',
authorization_endpoint: 'https://your-auth-server.com/oauth/authorize',
token_endpoint: 'https://your-auth-server.com/oauth/token',
registration_endpoint: 'https://your-auth-server.com/oauth/register', // REQUIRED
revocation_endpoint: 'https://your-auth-server.com/oauth/revoke',
scopes_supported: ['read:data', 'write:data'],
response_types_supported: ['code'],
response_modes_supported: ['query'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], // MUST include 'none'
code_challenge_methods_supported: ['S256'], // PKCE required
});
});
Critical Fields:
FieldRequirementWhyregistration_endpointREQUIREDClaude.ai uses DCR to register itselftoken_endpoint_auth_methods_supportedMust include 'none'Claude.ai is a public clientcode_challenge_methods_supportedMust include 'S256'PKCE is required for public clients
Dynamic Client Registration (DCR)#
This is the most commonly missed requirement. Claude.ai mandates DCR support (RFC 7591).
Registration Endpoint#
Endpoint: POST /oauth/register
app.post('/oauth/register', express.json(), async (req, res) => {
const {
redirect_uris,
client_name,
token_endpoint_auth_method = 'none',
grant_types = ['authorization_code', 'refresh_token'],
response_types = ['code'],
} = req.body;
// Validate redirect_uris if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' }); }
// Generate client credentials const clientId = crypto.randomUUID();
// Store the client (database, in-memory, etc.) await storeClient({ client_id: clientId, client_name, redirect_uris, token_endpoint_auth_method, grant_types, response_types, created_at: new Date(), });
// Return registration response res.status(201).json({ client_id: clientId, client_name, redirect_uris, token_endpoint_auth_method, grant_types, response_types, }); });
What Claude.ai Sends#
When Claude.ai registers, it sends something like:
{
"redirect_uris": ["https://claude.ai/oauth/callback"],
"client_name": "Claude",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}
Client Storage Considerations#
DCR creates a new client record for each registration
Consider implementing client deduplication by
redirect_uristo prevent unbounded growthClients registered via DCR should be marked as “public” (no secret validation)
Token Endpoint Requirements#
Support Public Clients (No Secret)#
Claude.ai registers with token_endpoint_auth_method: 'none'. Your token endpoint must accept requests without client_secret when:
The client was registered as a public client, AND
A valid PKCE
code_verifieris provided
app.post('/oauth/token', async (req, res) => {
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier } = req.body;
const client = await getClient(client_id);
if (client.token_endpoint_auth_method === 'none') { // Public client - validate PKCE instead of secret if (!code_verifier) { return res.status(400).json({ error: 'invalid_request', error_description: 'code_verifier required for public clients' }); } // Validate code_verifier against stored code_challenge const authCode = await getAuthorizationCode(code); if (!validatePKCE(code_verifier, authCode.code_challenge, authCode.code_challenge_method)) { return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }); } } else { // Confidential client - validate secret if (!client.verifySecret(client_secret)) { return res.status(401).json({ error: 'invalid_client' }); } }
// Issue tokens... });
PKCE Validation#
function validatePKCE(verifier: string, challenge: string, method: string): boolean {
if (method === 'S256') {
const hash = crypto.createHash('sha256').update(verifier).digest();
const computed = base64url(hash);
return computed === challenge;
} else if (method === 'plain') {
return verifier === challenge;
}
return false;
}
Common Pitfalls and Solutions#
Pitfall 1: Missing DCR Support#
Symptom: OAuth flow starts, browser opens, then immediately shows blank page (about:blank)
Solution: Implement /oauth/register endpoint and add registration_endpoint to authorization server metadata.
Pitfall 2: Token Endpoint Requires Secret#
Symptom: OAuth flow completes login, but fails silently after redirect
Solution: Add 'none' to token_endpoint_auth_methods_supported and update token endpoint to accept public clients with PKCE.
Pitfall 3: Wrong Transport Strategy#
Symptom: mcp-remote shows “stream is not readable” error
Solution: Support Streamable HTTP transport on your SSE endpoint. When clients POST without sessionId, treat it as Streamable HTTP initialization.
Pitfall 4: Session ID Mismatch#
Symptom: “Session not found” or “Missing sessionId” errors
Solution:
For SSE transport: use the transport’s internal
sessionIdproperty as the map keyFor Streamable HTTP: use
mcp-session-idheader for session lookup
// SSE - use transport's sessionId
const transport = new SSEServerTransport('/message', res);
activeTransports.set(transport.sessionId, transport); // Not your own ID
// Streamable HTTP - use header const sessionId = req.headers['mcp-session-id'];
Pitfall 5: Authorization Servers as Objects#
Symptom: OAuth discovery fails silently
Solution: authorization_servers in protected resource metadata must be an array of strings (issuer URLs), not objects:
// WRONG
authorization_servers: [{ issuer: 'https://auth.example.com', ... }]
// CORRECT authorization_servers: ['https://auth.example.com']
Pitfall 6: Localhost Redirect URIs#
Symptom: OAuth works for your test client but not Claude Desktop
Solution: Claude Desktop uses mcp-remote which creates localhost callbacks with dynamic ports. Your authorization server should allow any localhost or 127.0.0.1 redirect URI for system/MCP clients:
function isRedirectUriValid(uri: string, client: Client): boolean {
// Exact match
if (client.redirect_uris.includes(uri)) return true;
// For MCP clients, allow any localhost if (client.is_mcp_client) { const parsed = new URL(uri); if (['localhost', '127.0.0.1'].includes(parsed.hostname)) { return true; } }
return false; }
Testing Checklist#
Before deploying, verify each of these:
Discovery Endpoints#
GET /.well-known/oauth-protected-resourcereturns valid JSONGET /.well-known/oauth-authorization-serverreturns valid JSON withregistration_endpoint401 response includes
WWW-Authenticateheader withresource_metadataURL
Dynamic Client Registration#
POST /oauth/registeraccepts registration without authenticationReturns
client_idin responseStores client for later use
Authorization Flow#
/oauth/authorizeaccepts requests with PKCE (code_challenge,code_challenge_method)Shows login/consent UI
Redirects to
redirect_uriwithcodeandstate
Token Exchange#
/oauth/tokenaccepts requests withoutclient_secretfor public clientsValidates
code_verifieragainst storedcode_challengeReturns
access_tokenandrefresh_token
MCP Transport#
GET
/sseestablishes SSE connection (with auth)POST
/ssehandles both Streamable HTTP and SSE messagesPOST
/mcphandles Streamable HTTP (if separate endpoint)Session management works correctly
End-to-End#
Works with
mcp-remotebridgeWorks with Claude Desktop native UI (Settings > Connectors)
Reference Implementation#
MCP Server (TypeScript/Express)#
See the complete implementation at: mcp-server-oncalls
Key files:
src/remote-server.ts- Full remote server with OAuth supportTransport handling for both SSE and Streamable HTTP
OAuth discovery endpoints
Authentication middleware
Authorization Server (Python/Flask)#
Key endpoints needed:
/.well-known/oauth-authorization-server- Metadata/oauth/register- Dynamic Client Registration/oauth/authorize- Authorization endpoint/oauth/token- Token endpoint (supports public clients)/oauth/revoke- Token revocation
Database Schema#
-- OAuth Clients (including DCR-registered)
CREATE TABLE oauth_clients (
id SERIAL PRIMARY KEY,
client_id VARCHAR(64) UNIQUE NOT NULL,
client_secret_hash VARCHAR(255), -- NULL for public clients
client_name VARCHAR(255) NOT NULL,
redirect_uris TEXT NOT NULL, -- JSON array
token_endpoint_auth_method VARCHAR(20) DEFAULT 'none',
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- Authorization Codes (with PKCE) CREATE TABLE oauth_authorization_codes ( id SERIAL PRIMARY KEY, code VARCHAR(64) UNIQUE NOT NULL, client_id VARCHAR(64) NOT NULL, user_id INTEGER NOT NULL, redirect_uri TEXT NOT NULL, scope VARCHAR(500), code_challenge VARCHAR(128), -- PKCE code_challenge_method VARCHAR(10), -- 'S256' or 'plain' expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP );
-- Tokens CREATE TABLE oauth_tokens ( id SERIAL PRIMARY KEY, access_token VARCHAR(500) NOT NULL, refresh_token VARCHAR(128), client_id VARCHAR(64) NOT NULL, user_id INTEGER NOT NULL, scope VARCHAR(500), access_token_expires_at TIMESTAMP NOT NULL, refresh_token_expires_at TIMESTAMP, revoked_at TIMESTAMP );
Summary#
Implementing OAuth for MCP servers with Claude Desktop requires:
Transport Layer
Support Streamable HTTP (required)
Support SSE (recommended for compatibility)
Handle “http-first” strategy on SSE endpoint
OAuth Discovery
Protected Resource Metadata at
/.well-known/oauth-protected-resourceAuthorization Server Metadata at
/.well-known/oauth-authorization-serverProper 401 response with
WWW-Authenticateheader
Authorization Server
Dynamic Client Registration endpoint (
/oauth/register)Support for public clients (
token_endpoint_auth_method: 'none')PKCE support (S256)
Flexible localhost redirect URI handling
Token Endpoint
Accept requests without
client_secretfor public clientsValidate PKCE
code_verifierinstead
Following this guide will ensure your MCP server works seamlessly with Claude Desktop’s native connector UI.