Day 11 · Level 2 Begins · Spark Phase

OAuth 2.0 &
Authentication

Secure your MCP server for the real world — implement Authorization Code + PKCE, validate JWTs, handle token refresh, and design per-user scopes without compromising developer experience.

📅 Day 11 of 120 🔐 OAuth 2.1 / PKCE 🛡️ JWT Validation
Why MCP Needs Real Authentication
The servers you built in Level 1 used environment-variable tokens or no auth at all — perfectly fine for local stdio tools. But the moment your MCP server moves to HTTP and serves multiple users, you need a proper identity layer. Without it, every caller gets the same access regardless of who they are.
⚠️
The authentication gap in HTTP MCP: stdio servers inherit the OS user context of the spawning process. HTTP+SSE servers receive anonymous TCP connections. Unless you add auth, any client that knows your /sse URL can call all your tools with full privileges — including destructive ones.
graph LR A["👤 User / App"] -->|"wants access"| B["Authorization Server\n(Auth0, Cognito, self-hosted)"] B -->|"issues access token"| A A -->|"Bearer token in header"| C["MCP Server\n(your HTTP+SSE server)"] C -->|"validates token"| B C -->|"✅ authorized response"| A style A fill:#1e1b4b,stroke:#4f46e5,color:#a5b4fc style B fill:#1a2e1a,stroke:#10b981,color:#6ee7b7 style C fill:#0d2a28,stroke:#0d9488,color:#5eead4
What OAuth 2.0 adds to your MCP server:
OAuth 2.0 Roles — The Four Players
OAuth 2.0 defines four roles. In an MCP context, your server plays the Resource Server role — it holds the protected resources (tools, data) and validates tokens before granting access.
👤
Resource Owner
The human (or system) who owns the data and grants access
= your end user
🖥️
Client
The app requesting access on behalf of the resource owner
= MCP client / Claude Desktop
🔑
Authorization Server
Issues tokens after verifying identity and consent
= Auth0, Cognito, Keycloak
Resource Server
Hosts protected resources and validates tokens on every request
= YOUR MCP server
🍕
Pizza delivery analogy: You (Resource Owner) want pizza (data). The delivery app (Client) calls the restaurant (Authorization Server) to confirm you're a valid customer and get an order token. The delivery driver (Resource Server — your MCP server) checks that token before handing over the pizza.
OAuth Grant Types — Which One to Use
OAuth 2.0 defines several "grant types" — different flows for obtaining an access token. The MCP specification recommends Authorization Code + PKCE for all interactive flows. Here's why the others fall short.
Machine-to-machine
Client Credentials
Server-to-server flow with no user involvement. Uses a client ID + secret instead of user consent. Use this for server-to-server MCP integrations (CI pipelines, backend services) where there's no human user.
⚠ Avoid
Implicit Flow
Legacy browser flow where tokens are returned in the URL fragment. Tokens can leak via browser history and referrer headers. Deprecated in OAuth 2.1. Never use for new MCP servers.
⚠ Avoid
Resource Owner Password
Client collects username + password directly. Completely bypasses the authorization server's security UX. Deprecated. The user must trust the client with their credentials — never acceptable in MCP.
PKCE — Why It Exists & How It Works
PKCE (Proof Key for Code Exchange, pronounced "pixie") was invented to protect the Authorization Code flow from interception attacks, especially on mobile and CLI apps where client secrets can't be kept truly secret. It's now mandatory in OAuth 2.1 for all public clients — including MCP clients.
PKCE Parameters
code_verifier
A cryptographically random string of 43–128 chars. Generated once per auth attempt, kept secret by the client. Never sent in the redirect URL.
code_challenge
The SHA-256 hash of the code_verifier, Base64url-encoded. Sent in the authorization request. An attacker who intercepts this URL cannot reverse it to the verifier.
code_challenge_method
Always S256 (SHA-256). The older plain method provides no security — never use it.
state
A random string included in the authorization request and returned in the callback. Prevents CSRF attacks. Always verify it matches before exchanging the code.
sequenceDiagram participant C as MCP Client participant B as Browser / User participant AS as Auth Server participant MS as MCP Server C->>C: Generate code_verifier (random 64 bytes) C->>C: code_challenge = BASE64URL(SHA256(verifier)) C->>B: Redirect to /authorize?code_challenge=...&method=S256&state=xyz B->>AS: GET /authorize (user sees consent screen) AS-->>B: Redirect to callback?code=AUTH_CODE&state=xyz B->>C: Callback received — verify state=xyz ✓ C->>AS: POST /token { code, code_verifier, client_id } AS->>AS: SHA256(verifier) == code_challenge? ✓ AS-->>C: { access_token, refresh_token, expires_in } C->>MS: GET /sse Authorization: Bearer access_token MS->>AS: Validate token (introspect or verify JWT) MS-->>C: SSE connection established ✓
// src/auth/pkce.ts — PKCE helpers for an MCP client
import { createHash, randomBytes } from 'crypto';

export function generateCodeVerifier(): string {
  // 64 random bytes → 86 Base64url chars (well within 43–128 range)
  return randomBytes(64).toString('base64url');
}

export function generateCodeChallenge(verifier: string): string {
  return createHash('sha256').update(verifier).digest('base64url');
}

export function generateState(): string {
  return randomBytes(32).toString('hex');
}

// Build the authorization URL
export function buildAuthUrl(params: {
  authorizationEndpoint: string;
  clientId: string;
  redirectUri: string;
  scopes: string[];
  codeChallenge: string;
  state: string;
}): string {
  const url = new URL(params.authorizationEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', params.clientId);
  url.searchParams.set('redirect_uri', params.redirectUri);
  url.searchParams.set('scope', params.scopes.join(' '));
  url.searchParams.set('code_challenge', params.codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  url.searchParams.set('state', params.state);
  return url.toString();
}

// Exchange auth code for tokens
export async function exchangeCode(params: {
  tokenEndpoint: string;
  clientId: string;
  code: string;
  codeVerifier: string;
  redirectUri: string;
}): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
  const res = await fetch(params.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: params.clientId,
      code: params.code,
      code_verifier: params.codeVerifier,
      redirect_uri: params.redirectUri,
    }),
  });
  if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`);
  return res.json();
}src/auth/pkce.ts
JWT Validation on the MCP Server
Your MCP server is the resource server — it must validate every incoming Bearer token before processing any request. There are two strategies: local JWT verification (fast, offline) and token introspection (always fresh, requires a network call). Most production deployments use local verification with periodic JWKS key rotation.
Header
Algorithm + key ID used to sign the token
{ "alg": "RS256", "kid": "key-2026-01" }
Payload
Claims: who, what, when, scopes
{ "sub": "user_123", "iss": "https://auth.example.com", "exp": 1752000000, "scope": "tools:read" }
Signature
Cryptographic proof — only the auth server can forge it
RSA-SHA256( base64(header) + "." + base64(payload) )
// src/auth/jwt-validator.ts — Production JWT validation for MCP HTTP server
import { createRemoteJWKSet, jwtVerify } from 'jose'; // npm install jose

const JWKS_URI    = process.env.JWKS_URI    ?? 'https://YOUR_DOMAIN/.well-known/jwks.json';
const AUDIENCE    = process.env.JWT_AUDIENCE ?? 'https://your-mcp-server.com';
const ISSUER      = process.env.JWT_ISSUER   ?? 'https://YOUR_DOMAIN/';

// Cache the JWKS (automatically refreshed by jose when keys rotate)
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));

export interface TokenClaims {
  sub: string;           // user ID
  iss: string;           // issuer
  aud: string | string[]; // audience
  exp: number;           // expiry timestamp
  scope?: string;        // space-delimited scopes
  email?: string;
  [key: string]: unknown;
}

export async function validateToken(rawToken: string): Promise<TokenClaims> {
  const { payload } = await jwtVerify(rawToken, JWKS, {
    issuer:   ISSUER,
    audience: AUDIENCE,
  });
  return payload as TokenClaims;
}

export function hasScope(claims: TokenClaims, required: string): boolean {
  const scopes = (claims.scope ?? '').split(' ');
  return scopes.includes(required);
}

// ── Express middleware ──────────────────────────────────────────────────────
import type { Request, Response, NextFunction } from 'express';

declare global {
  namespace Express {
    interface Request { tokenClaims?: TokenClaims }
  }
}

export function requireAuth(requiredScope?: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const auth = req.headers.authorization ?? '';
    if (!auth.startsWith('Bearer ')) {
      res.status(401).json({ error: 'Missing Bearer token' });
      return;
    }
    try {
      const claims = await validateToken(auth.slice(7));
      if (requiredScope && !hasScope(claims, requiredScope)) {
        res.status(403).json({ error: `Insufficient scope — requires: ${requiredScope}` });
        return;
      }
      req.tokenClaims = claims; // attach to request for downstream use
      next();
    } catch (err) {
      res.status(401).json({ error: 'Invalid or expired token' });
    }
  };
}
// Usage:
// app.get('/sse',      requireAuth('mcp:read'),  sseHandler);
// app.post('/message', requireAuth('mcp:read'),  postHandler);src/auth/jwt-validator.ts
📦
jose is the gold standard for JWT validation in Node.js. It supports RS256, ES256, JWKS rotation, audience/issuer checks, and works edge runtimes (Cloudflare Workers, Deno). Install: npm install jose.
Designing Scopes for MCP Tools
Scopes are coarse-grained permissions attached to a token. A well-designed scope hierarchy lets you mint tokens that can call read-only tools but not destructive ones — critical for least-privilege access.
ScopeAccess levelExample tools allowedType
mcp:readRead all resources and call read-only toolssearch_repos, get_file_content, list_usersREAD
mcp:writeCall tools that create or modify datacreate_issue, update_record, send_notificationWRITE
mcp:adminAll tools including destructive operationsdelete_repo, rotate_secrets, purge_cacheADMIN
github:reposGitHub-specific tool subsetsearch_repos, get_file_content, create_pr_draftRESOURCE
github:issuesIssue management tools onlylist_issues, create_issue, close_issueRESOURCE
offline_accessAllow refresh token issuance(enables background token refresh)OAUTH
// src/auth/scope-guard.ts — per-tool scope enforcement
// Map each tool name to its required scope(s)
const TOOL_SCOPES: Record<string, string[]> = {
  search_repos:       ['mcp:read', 'github:repos'],   // either scope works
  get_file_content:   ['mcp:read', 'github:repos'],
  analyze_complexity: ['mcp:read'],
  create_pr_draft:    ['mcp:write', 'github:repos'],
  create_issue:       ['mcp:write', 'github:issues'],
  delete_repo:        ['mcp:admin'],
};

export function canCallTool(claims: TokenClaims, toolName: string): boolean {
  const required = TOOL_SCOPES[toolName];
  if (!required) return true; // unregistered tools are unrestricted (adjust per policy)
  const tokenScopes = (claims.scope ?? '').split(' ');
  // Token needs to have AT LEAST ONE of the required scopes
  return required.some(s => tokenScopes.includes(s));
}

// Integrate into tool handler:
server.tool('delete_repo', 'Delete a GitHub repository', { name: z.string() }, async (args, { server: srv }) => {
  // In HTTP servers, attach claims to the request context
  const claims = getRequestClaims(); // pull from AsyncLocalStorage context
  if (!canCallTool(claims, 'delete_repo')) {
    return { isError: true, content: [{ type: 'text' as const,
      text: 'Forbidden: token missing mcp:admin scope' }] };
  }
  // ... proceed with deletion
});src/auth/scope-guard.ts
Automatic Token Refresh
Access tokens are short-lived (typically 15 min–1 hour). A good MCP client silently refreshes tokens before they expire so long-running sessions don't break mid-conversation. Here's the pattern.
// src/auth/token-manager.ts — silent token refresh with proactive renewal
interface TokenSet {
  accessToken:  string;
  refreshToken?: string;
  expiresAt:    number; // Date.now() + expires_in * 1000
}

export class TokenManager {
  private tokens: TokenSet | null = null;
  private refreshPromise: Promise<TokenSet> | null = null;

  constructor(
    private readonly tokenEndpoint: string,
    private readonly clientId: string,
  ) {}

  async getAccessToken(): Promise<string> {
    if (!this.tokens) throw new Error('Not authenticated — call setTokens() first');

    // Proactive refresh: renew if less than 60 seconds remain
    const expiresIn = this.tokens.expiresAt - Date.now();
    if (expiresIn < 60_000) {
      this.tokens = await this.refresh();
    }
    return this.tokens.accessToken;
  }

  setTokens(set: TokenSet) { this.tokens = set; }

  private async refresh(): Promise<TokenSet> {
    // Deduplicate concurrent refresh calls
    if (this.refreshPromise) return this.refreshPromise;

    this.refreshPromise = (async () => {
      if (!this.tokens?.refreshToken) throw new Error('No refresh token available');
      const res = await fetch(this.tokenEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type:    'refresh_token',
          refresh_token: this.tokens.refreshToken,
          client_id:     this.clientId,
        }),
      });
      if (!res.ok) throw new Error(`Token refresh failed: ${await res.text()}`);
      const data = await res.json();
      return {
        accessToken:  data.access_token,
        refreshToken: data.refresh_token ?? this.tokens!.refreshToken,
        expiresAt:    Date.now() + data.expires_in * 1000,
      };
    })().finally(() => { this.refreshPromise = null; });

    return this.refreshPromise;
  }
}

// Usage in the HTTP+SSE client transport:
// const mgr = new TokenManager(TOKEN_ENDPOINT, CLIENT_ID);
// mgr.setTokens({ accessToken: '...', refreshToken: '...', expiresAt: Date.now() + 3600_000 });
//
// const transport = new SSEClientTransport(new URL(SERVER_URL + '/sse'), {
//   requestInit: async () => ({
//     headers: { Authorization: `Bearer ${await mgr.getAccessToken()}` }
//   }),
// });src/auth/token-manager.ts
🔁
Deduplication is critical: If two concurrent tool calls both see an expired token and both try to refresh simultaneously, you'd make two refresh requests — and the first response would invalidate the second refresh token (rotation). The refreshPromise deduplication pattern ensures only one refresh happens at a time.
Per-User Context in Tool Handlers
Once you have a validated token, you need to thread the user's identity into your tool handlers — so search_repos searches their GitHub, not a shared service account. Use Node.js AsyncLocalStorage to pass request context without polluting every function signature.
// src/auth/context.ts — AsyncLocalStorage for per-request auth context
import { AsyncLocalStorage } from 'async_hooks';
import type { TokenClaims } from './jwt-validator.js';

export interface RequestContext {
  userId:    string;
  userEmail: string;
  scopes:    string[];
  // Per-user API credentials (fetched from your secrets store)
  githubToken?: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

export function getContext(): RequestContext {
  const ctx = requestContext.getStore();
  if (!ctx) throw new Error('No request context — are you inside a tool handler?');
  return ctx;
}

// ── Attach to express SSE + POST handlers ─────────────────────────────────
// In your Express app, wrap the handler in requestContext.run():
app.get('/sse', requireAuth('mcp:read'), async (req, res) => {
  const claims = req.tokenClaims!;
  const ctx: RequestContext = {
    userId:     claims.sub,
    userEmail:  claims.email ?? '',
    scopes:     (claims.scope ?? '').split(' '),
    githubToken: await fetchUserGitHubToken(claims.sub), // from your secrets store
  };

  requestContext.run(ctx, async () => {
    const transport = new SSEServerTransport('/message', res);
    const mcpServer = buildMcpServer(); // creates a new McpServer per connection
    await mcpServer.connect(transport);
  });
});

// ── Inside a tool handler, get the user's context ─────────────────────────
server.tool('search_repos', 'Search GitHub', { query: z.string() }, async ({ query }) => {
  const { userId, githubToken } = getContext();
  // Each user gets results from their own GitHub account
  const results = await githubSearch(query, githubToken);
  return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
});src/auth/context.ts
Dynamic Client Registration (DCR)
The MCP specification includes optional support for Dynamic Client Registration — a way for MCP clients to automatically register themselves with your authorization server without you manually creating client IDs. This is what enables true plug-and-play OAuth for third-party MCP clients.
// src/auth/dcr.ts — Dynamic Client Registration (RFC 7591)
// Your auth server exposes POST /oauth/register
// MCP clients call it once to get a client_id automatically

export async function registerClient(params: {
  registrationEndpoint: string;
  clientName: string;
  redirectUris: string[];
  scopes: string[];
  grantTypes?: string[];
}): Promise<{ client_id: string; client_secret?: string }> {
  const res = await fetch(params.registrationEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_name:    params.clientName,
      redirect_uris:  params.redirectUris,
      scope:          params.scopes.join(' '),
      grant_types:    params.grantTypes ?? ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      token_endpoint_auth_method: 'none', // public client (PKCE, no secret)
    }),
  });
  if (!res.ok) throw new Error(`DCR failed: ${await res.text()}`);
  return res.json();
}

// ── MCP server discovery endpoint ─────────────────────────────────────────
// Expose /.well-known/oauth-protected-resource so clients auto-discover auth
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
  res.json({
    resource:               'https://your-mcp-server.com',
    authorization_servers:  ['https://your-auth-server.com'],
    scopes_supported:       ['mcp:read', 'mcp:write', 'mcp:admin', 'offline_access'],
    bearer_methods_supported: ['header'],
  });
});src/auth/dcr.ts
🔍
The discovery endpoint is what makes MCP auth seamless. When a new MCP client connects and gets a 401 with a WWW-Authenticate header pointing to your discovery URL, it can automatically find your authorization server, register itself, and complete the PKCE flow — all without any manual configuration by the user.
Machine-to-Machine: Client Credentials Flow
CI pipelines, backend microservices, and automated test suites that use MCP don't have a human to click through a consent screen. They use the Client Credentials grant — authenticating as the service itself, not as a user.
// src/auth/client-credentials.ts — M2M token acquisition
export class MachineTokenManager {
  private cachedToken: { value: string; expiresAt: number } | null = null;

  constructor(
    private readonly tokenEndpoint: string,
    private readonly clientId:      string,
    private readonly clientSecret:  string,
    private readonly scopes:        string[],
  ) {}

  async getToken(): Promise<string> {
    // Serve from cache if still valid (with 60s buffer)
    if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 60_000) {
      return this.cachedToken.value;
    }

    const res = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type':  'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`,
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        scope:      this.scopes.join(' '),
      }),
    });

    if (!res.ok) throw new Error(`M2M token request failed: ${await res.text()}`);
    const data = await res.json();

    this.cachedToken = {
      value:     data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
    };
    return this.cachedToken.value;
  }
}

// Usage in a CI/CD pipeline client:
const m2m = new MachineTokenManager(
  process.env.TOKEN_ENDPOINT!,
  process.env.CLIENT_ID!,
  process.env.CLIENT_SECRET!,   // stored in CI secrets, never in code
  ['mcp:read', 'github:repos'],
);

const transport = new SSEClientTransport(new URL(SERVER_URL + '/sse'), {
  requestInit: async () => ({
    headers: { Authorization: `Bearer ${await m2m.getToken()}` }
  }),
});src/auth/client-credentials.ts
🚨
Never expose client secrets in client-side code or public repos. For M2M flows, the client_secret must live in a secrets manager (AWS Secrets Manager, GitHub Actions secrets, Vault) and be injected as an environment variable at runtime. Rotate secrets regularly and revoke them immediately if compromised.
OAuth Security Checklist for MCP Servers
OAuth is often implemented correctly in theory but broken in practice due to small configuration mistakes. Run through this list before shipping any MCP server with OAuth.
Real-World Auth Patterns
Four patterns that cover the most common MCP authentication scenarios in production deployments.
🏢
Org-wide MCP Server (Auth0 / Okta)
Use Auth0 or Okta as the authorization server. All employees log in with SSO. Tool scopes map to Okta groups. No custom auth code — just JWKS validation middleware.
🐙
GitHub OAuth App
Use GitHub as both the auth server and identity provider. Users authorize your GitHub OAuth App. The resulting token both identifies them AND works as a GitHub API credential — no separate token store needed.
🔑
API Key + JWT Hybrid
For developer-facing MCPs: accept either a long-lived API key (for scripts) or a short-lived JWT (for interactive sessions). The validator checks both formats and normalizes to the same TokenClaims interface.
🤖
Service Account Pattern
For each downstream API (GitHub, Slack, Jira), create a dedicated service account. Store its token in AWS Secrets Manager. Tools retrieve it by service name — users never see these credentials and can't exfiltrate them.
// Quick-start: Auth0 + Express MCP server — complete setup
import express from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { requireAuth } from './auth/jwt-validator.js';
import { requestContext } from './auth/context.js';

const app = express();
app.use(express.json());

// AUTH0 ENV VARS:
// JWKS_URI=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json
// JWT_ISSUER=https://YOUR_DOMAIN.auth0.com/
// JWT_AUDIENCE=https://your-mcp-server.com

const sessions = new Map();

app.get('/sse', requireAuth('mcp:read'), async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');

  const claims = req.tokenClaims!;
  const ctx = {
    userId: claims.sub,
    userEmail: claims.email ?? '',
    scopes: (claims.scope ?? '').split(' '),
  };

  requestContext.run(ctx, async () => {
    const transport = new SSEServerTransport('/message', res);
    sessions.set(transport.sessionId, transport);
    const server = buildMcpServer(); // registerTools(server), etc.
    await server.connect(transport);
    req.on('close', () => sessions.delete(transport.sessionId));
  });
});

app.post('/message', requireAuth('mcp:read'), async (req, res) => {
  const transport = sessions.get(req.query.sessionId as string);
  if (!transport) { res.status(404).json({ error: 'Session not found' }); return; }
  await transport.handlePostMessage(req, res, req.body);
});

app.listen(3000, () => console.error('[MCP Auth] Listening on :3000'));complete auth setup
OAuth & Authentication Check
5 questions covering OAuth roles, PKCE, JWT validation, scopes, and security gotchas. Score 5/5 to confirm Level 2 is off to a strong start.
Q1In the OAuth 2.0 model, your MCP HTTP+SSE server plays which role?
AAuthorization Server — it issues access tokens to clients
BResource Owner — it owns the data being protected
CResource Server — it validates tokens and serves protected resources (tools, data)
DClient — it requests access on behalf of the user
Q2In PKCE, what is the code_challenge and why is it safe to send in the browser redirect URL?
AIt's the raw random verifier — it's safe because it's encrypted with TLS
BIt's the SHA-256 hash of the code_verifier — an attacker who intercepts it cannot reverse the hash to recover the verifier
CIt's the access token itself, encoded in Base64
DIt's a one-time nonce that expires after 30 seconds
Q3A CI pipeline needs to call your MCP server with no user interaction. Which OAuth grant type is correct?
AAuthorization Code + PKCE
BClient Credentials — the pipeline authenticates as a service, not a user
CImplicit Flow
DResource Owner Password — pass the admin username and password
Q4Your TokenManager detects the access token has less than 60 seconds of remaining life. Two tool calls arrive simultaneously and both trigger getAccessToken(). Without the refreshPromise deduplication, what bad thing happens?
ABoth calls succeed — the auth server allows duplicate refresh requests
BBoth calls make a refresh request; with token rotation enabled, the first response invalidates the refresh token used by the second — causing a second-call auth failure
CThe second call uses the cached token and never refreshes
DNode.js event loop serializes async calls so only one refresh can happen at a time
Q5A user configures their MCP client with a token that has scope: "mcp:read github:repos". They ask Claude to call the delete_repo tool which requires mcp:admin. What should your server return?
AA JSON-RPC protocol error with code -32601 MethodNotFound
BHTTP 401 Unauthorized on the /message POST
CA tool result with isError: true and a message explaining the insufficient scope
DHTTP 403 — reject at the middleware level before the tool handler runs
← Previous Day
Day 10: Error Handling & Resilience
Circuit breakers, retry strategies & graceful degradation
Next Day →
Day 12: Real API Integrations
GitHub, Slack & database MCP servers with live data