Skip to content

MCP Remote Auth Flow: 3-Legged OAuth (3LO) Handshake

Overview of the Players

┌─────────────┐     ┌─────────────────┐     ┌─────────────────────┐
│  MCP Client │     │  MCP Server     │     │  Authorization      │
│  (e.g. IDE) │     │  (Remote)       │     │  Server (AS)        │
└─────────────┘     └─────────────────┘     └─────────────────────┘

Step 1: Initial Connection & Discovery

The client first hits the MCP server to discover its capabilities and auth requirements.

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

The server responds with metadata:

{
  "issuer": "https://mcp-server.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "scopes_supported": [
    "mcp:read",
    "mcp:write",
    "mcp:tools:execute",
    "mcp:resources:read",
    "mcp:prompts:read"
  ],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"]
}

The client now knows what scopes exist and where to send the user


Step 2: Client Registration (Dynamic, if needed)

If the client hasn't been pre-registered, MCP spec allows dynamic client registration:

POST https://auth.example.com/oauth/register
Content-Type: application/json

{
  "client_name": "My MCP Client",
  "redirect_uris": ["http://localhost:3000/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "scope": "mcp:read mcp:tools:execute"   // <-- desired scopes declared here
}

Response:

{
  "client_id": "abc123",
  "client_secret": "xyz789",    // only if confidential client
  "client_id_issued_at": 1234567890
}


Step 3: PKCE Setup (Client Side)

Before redirecting the user, the client generates PKCE values:

// Client generates these locally
const codeVerifier = generateRandomString(64)
// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

const codeChallenge = base64url(sha256(codeVerifier))
// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Step 4: Authorization Request (The "3" in 3LO)

Client redirects the user's browser to the AS with requested scopes:

GET https://auth.example.com/oauth/authorize?
  response_type=code
  &client_id=abc123
  &redirect_uri=http://localhost:3000/callback
  &scope=mcp:read%20mcp:tools:execute     // <-- scopes requested HERE
  &state=randomCSRFtoken
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

The user then: 1. Logs in (if not already) 2. Sees a consent screen showing exactly what scopes are being requested 3. Approves or denies


Step 5: Authorization Code Returned

After user consent, AS redirects back:

GET http://localhost:3000/callback?
  code=SplxlOBeZQQYbYS6WxSbIA
  &state=randomCSRFtoken     // client verifies this matches

Step 6: Token Exchange

Client exchanges the code for tokens:

POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=http://localhost:3000/callback
&client_id=abc123
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

AS responds with tokens:

{
  "access_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "8xLOxBtZp8",
  "scope": "mcp:read mcp:tools:execute"   // <-- AS confirms GRANTED scopes
                                           // may differ from what was requested!
}

⚠️ Important: The AS may grant fewer scopes than requested. The client must check what was actually granted.


Step 7: MCP Connection with Token

Now the client connects to the MCP server using the token:

// SSE or WebSocket connection
GET https://mcp-server.example.com/sse
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

Step 8: MCP Initialize Handshake

Over the established connection:

// Client → Server
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": {
      "name": "MyMCPClient",
      "version": "1.0.0"
    }
  }
}
// Server → Client
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {},
      "resources": { "subscribe": true },
      "prompts": {}
    },
    "serverInfo": {
      "name": "ExampleMCPServer",
      "version": "2.0.0"
    }
  }
}

How Scopes Gate MCP Operations

This is where it gets interesting — scopes map to MCP primitives:

┌─────────────────────┬──────────────────────────────────────┐
│  Scope              │  Allowed MCP Operations              │
├─────────────────────┼──────────────────────────────────────┤
│ mcp:read            │  tools/list, resources/list,         │
│                     │  prompts/list                        │
├─────────────────────┼──────────────────────────────────────┤
│ mcp:tools:execute   │  tools/call                          │
├─────────────────────┼──────────────────────────────────────┤
│ mcp:resources:read  │  resources/read, resources/subscribe │
├─────────────────────┼──────────────────────────────────────┤
│ mcp:prompts:read    │  prompts/get                         │
├─────────────────────┼──────────────────────────────────────┤
│ mcp:write           │  resources/write (if supported)      │
└─────────────────────┴──────────────────────────────────────┘

The MCP server validates on every request:

// Pseudocode on the MCP server
function handleToolCall(request, token) {
  const scopes = introspectToken(token)  // or decode JWT

  if (!scopes.includes("mcp:tools:execute")) {
    return {
      jsonrpc: "2.0",
      error: {
        code: -32001,   // or HTTP 403
        message: "Insufficient scope: requires mcp:tools:execute"
      }
    }
  }

  // proceed with tool execution
}

Token Refresh Flow

POST https://auth.example.com/oauth/token

grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=abc123
&scope=mcp:read mcp:tools:execute   // can request same or FEWER scopes

Full Flow Diagram

Client          Browser         Auth Server      MCP Server
  │                │                │                │
  │──discover─────────────────────────────────────►  │
  │◄──scopes_supported──────────────────────────────  │
  │                │                │                │
  │──redirect user►│                │                │
  │                │──authz request─►                │
  │                │   (w/ scopes)  │                │
  │                │◄──login+consent│                │
  │                │──approve──────►│                │
  │                │◄──auth code────│                │
  │◄──callback─────│                │                │
  │                │                │                │
  │──token exchange────────────────►│                │
  │◄──access_token + granted scopes─│                │
  │                │                │                │
  │──connect w/ Bearer token───────────────────────►│
  │──initialize────────────────────────────────────►│
  │◄──capabilities──────────────────────────────────│
  │                │                │                │
  │──tools/call────────────────────────────────────►│
  │             (server checks scopes on token)      │
  │◄──result or 403─────────────────────────────────│

Key Gotchas

1. Scope downscoping at the AS

Requested:  mcp:read mcp:tools:execute mcp:write
Granted:    mcp:read mcp:tools:execute
// Client must handle this gracefully

2. The MCP server might not be the AS - MCP server could delegate to an external IdP (GitHub, Google, etc.) - Token introspection or JWT validation happens server-side

3. Per-tool scope granularity Some implementations get more granular:

mcp:tools:execute:calculator
mcp:tools:execute:file-system    // more sensitive, requires explicit grant

4. State parameter is critical Must validate state on callback to prevent CSRF attacks during the auth flow