If you have experimented with the Model Context Protocol (MCP) recently, chances are you followed a quick tutorial that looked something like this:

  1. Create a quick server.ts file using @modelcontextprotocol/sdk.
  2. Add a tool that adds two numbers or fetches a mock weather report.
  3. Wire it into your desktop AI client (like Claude Desktop or Cursor) via stdio (standard input/output) running locally on your laptop.
  4. Hardcode your personal API key or database string in a .env file.

It feels like magic on day one! Your local AI assistant can execute functions on your machine.

Then you want to use it for real, and the questions start:

"How do I deploy this to the cloud so my whole team or external AI agents can use it?"
"If it's on the public internet, how do we stop random people from calling our tools?"
"We can't put our master database key on every employee's laptop. How does the server know **who* is calling a tool?"*

Suddenly, the simple localhost setup isn't enough.

If you expose an MCP server to the web without authentication, anyone can trigger your tools or read your data. And if you give everyone one shared API key, you have no user identity, no audit trail, and no way to say "User A can read tasks, but only User B can create them."

The answer is OAuth 2.0 (specifically OAuth 2.1 with PKCE)—the official, specification-backed way to authenticate remote MCP servers.

In this guide, we will build a complete, production-ready, cloud-deployable MCP server called TaskPulse (a team task and project management service). We'll cover the three pillars of MCP (Tools, Resources, and Prompts) and show you how to protect them with interactive user consent, PKCE, and JWTs.

Table of Contents

  1. The "Localhost Trap" vs. Remote Cloud MCP
  2. The 3 Pillars of MCP: Tools, Resources, and Prompts
  3. OAuth 2.0 & MCP: Demystifying the Concepts
  4. The Complete MCP OAuth Handshake (Visual Diagram)
  5. Project Architecture Blueprint
  6. Step 1: Client Store & Dynamic Registration
  7. Step 2: The OAuth 2.1 Engine (PKCE, JWTs & Token Rotation)
  8. Step 3: The Interactive Consent Screen
  9. Step 4: Express Server & MCP Transports (SSE & HTTP)
  10. Step 5: Implementing MCP Tools, Resources & Prompts with User Context
  11. Testing Your Server (Locally & in Claude/Cursor)
  12. Production Gotchas & Best Practices
  13. Summary Checklist

1. The "Localhost Trap" vs. Remote Cloud MCP

Let's compare the two ways an MCP server can run:

+--------------------------------------------------------------------------+ | LOCALHOST MCP (stdio) | | Desktop Client (Claude/Cursor) ---> Spawn local process (node server.js) | | - Runs only on your local machine | | - Hardcoded credentials in a local .env file | | - Single-user: No concept of who is logged in | +--------------------------------------------------------------------------+ VS. +--------------------------------------------------------------------------+ | REMOTE PRODUCTION MCP (HTTP / SSE / Cloud) | | AI Client (Desktop, Web, Agents) ---> Internet ---> Cloud Web Server | | - Deployed on your favorite cloud (DigitalOcean, AWS, Render, Fly.io) | | - Multi-tenant: Alice sees Alice's team tasks; Bob sees Bob's tasks | | - Users authenticate via browser login with their own credentials | | - Client apps receive short-lived tokens, never raw passwords | +--------------------------------------------------------------------------+ 

When you deploy an MCP server as a web service, clients connect over HTTP using:

  • Server-Sent Events (SSE): The standard streaming protocol (/sse for server-to-client streaming, and /messages?sessionId=... for client-to-server calls).
  • Streamable HTTP: The modern bidirectional chunked transport (/mcp).

Because these endpoints are publicly reachable, you need standard authentication.

2. The 3 Pillars of MCP: Tools, Resources, and Prompts

Before adding auth, let's make sure we understand what an MCP server actually exposes to an AI model:

 +-----------------------------------+ | MCP SERVER | +-----------------+-----------------+ | +--------------------------+--------------------------+ | | | v v v 1. TOOLS 2. RESOURCES 3. PROMPTS "Do something" "Read something" "Guide the conversation" Functions the model Structured reference data Pre-built prompts and can execute (e.g. the model can read (e.g. workflows with arguments create a task, query project specs, schemas, (e.g. generate daily a database). live status). standup summary). 
  1. Tools (server.tool(...)): Interactive functions with typed schemas (using Zod). The AI decides when to call them based on the conversation.
  2. Resources (server.resource(...)): File-like data streams or API objects identified by custom URI schemes (e.g., taskpulse://projects/proj_123). The client can read them as background context.
  3. Prompts (server.prompt(...)): Slash-command style templates that help users kick off complex workflows (e.g., /summarize-sprint).

In a real app, all three must be protected so users only see and touch what they are authorized to access.

3. OAuth 2.0 & MCP: Demystifying the Concepts

If you're new to OAuth, the acronyms can feel overwhelming. Here is what you need to know in plain terms:

1. OAuth 2.0 vs. OAuth 2.1

  • OAuth 2.0 is the industry standard for delegated authorization (letting an app act on your behalf without knowing your password).
  • OAuth 2.1 is the modern, cleaned-up standard. It removes old, insecure practices (like passing passwords directly through the client) and mandates PKCE.

2. PKCE (Proof Key for Code Exchange — pronounced "pixy")

  • Desktop apps (like Claude Desktop or Cursor) are public clients; they cannot keep a client secret safe because users could extract it from memory or local files.
  • PKCE eliminates the need for hardcoded secrets:
    1. The AI client creates a random secret string (code_verifier).
    2. It hashes that string with SHA-256 to create a code_challenge.
    3. It sends the code_challenge when launching the browser login.
    4. Once the user approves, the client exchanges the temporary code for tokens by presenting the original code_verifier.
    5. The server hashes the verifier. If it matches the original challenge, access is granted!

3. Protected Resource Metadata (RFC 9728)

  • How does an AI client know where your login screen is?
  • When the client first hits your protected endpoint (GET /sse) without a token, your server responds with:
 HTTP/1.1 401 Unauthorized  WWW-Authenticate: Bearer resource_metadata="https://api.yourdomain.com/.well-known/oauth-protected-resource" 
  • The client fetches that URL to discover who your authorization server is and what scopes are available.

4. Authorization Server Metadata (RFC 8414)

  • Exposed at /.well-known/oauth-authorization-server.
  • This is the server's directory, advertising the /authorize URL, /token URL, supported scopes, and PKCE algorithms (S256).

5. Dynamic Client Registration (RFC 7591)

  • What happens when a brand-new AI agent or client wants to connect?
  • Rather than forcing an engineer to create an API key in a dashboard, the client calls POST /register with its name and redirect URI, and the server generates a client_id automatically.

4. The Complete MCP OAuth Handshake (Visual Diagram)

Here is how the authentication handshake works from start to finish:

sequenceDiagram autonumber actor User as User (Developer) participant Client as AI Client (Claude / Cursor) participant MCP as MCP Web Server (Express) participant Auth as OAuth Provider & UI participant DB as App Database / API Client->>MCP: 1. GET /sse (No token) MCP-->>Client: 2. 401 Unauthorized (points to /.well-known/oauth-protected-resource) Client->>MCP: 3. Fetch OAuth Metadata (PRM & ASM) MCP-->>Client: 4. Returns /authorize and /token endpoints + scopes Client->>Client: 5. Generates PKCE pair (code_verifier + code_challenge) Client->>User: 6. Opens browser to /authorize?client_id=...&code_challenge=... User->>Auth: 7. Sees consent page, logs in with email & password Auth->>DB: 8. Verifies user credentials & tenant DB-->>Auth: 9. User verified (User ID: usr_42, Org: org_devs) Auth-->>User: 10. Redirects to http://localhost/callback?code=AUTH_CODE Client->>Client: 11. Captures AUTH_CODE from local callback Client->>MCP: 12. POST /token (code + code_verifier) Note over MCP: Verifies SHA256(code_verifier) == code_challenge MCP-->>Client: 13. Returns { access_token (JWT), refresh_token, expires_in: 3600 } Client->>MCP: 14. GET /sse (Header: Authorization: Bearer <access_token>) MCP-->>Client: 15. 200 OK (Stream connected!) Client->>MCP: 16. Call tool "create_task" Note over MCP: Reads User ID from JWT; creates task under usr_42 MCP-->>Client: 17. Returns { success: true, taskId: "task_101" }

5. Project Architecture Blueprint

Let's build TaskPulse MCP. Here is how we organize the codebase:

taskpulse-mcp/ ├── src/ │ ├── auth/ │ │ ├── clientStore.ts # Registry of allowed AI clients (Claude, Cursor, Dynamic) │ │ ├── oauthProvider.ts # Core OAuth 2.1 engine (implements OAuthServerProvider) │ │ ├── types.ts # Session, token, and scope interfaces │ │ └── views/ │ │ └── consentPage.ts # Clean HTML consent & login screen │ ├── db/ │ │ └── taskStore.ts # In-memory or database layer for tasks and users │ ├── server.ts # McpServer: Tools, Resources, and Prompts │ └── index.ts # Express app, Transports (SSE/HTTP), OAuth router ├── package.json └── tsconfig.json 

Installation

mkdir taskpulse-mcp && cd taskpulse-mcp npm init -y npm install @modelcontextprotocol/sdk express dotenv zod npm install --save-dev typescript @types/node @types/express ts-node npx tsc --init 

Step 1: Client Store & Dynamic Registration

First, we need a store to track which AI applications are allowed to connect.

Create src/auth/clientStore.ts:

import { randomUUID } from "node:crypto"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; export class TaskPulseClientStore implements OAuthRegisteredClientsStore { private clients = new Map<string, OAuthClientInformationFull>(); constructor() { this.seedDefaultClients(); } private seedDefaultClients(): void { const knownClients: OAuthClientInformationFull[] = [ { client_id: "claude-desktop", client_name: "Claude Desktop", redirect_uris: ["http://localhost/callback", "http://127.0.0.1/callback"], token_endpoint_auth_method: "none", // Public client (uses PKCE) response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], client_id_issued_at: Math.floor(Date.now() / 1000), }, { client_id: "cursor-ide", client_name: "Cursor IDE", redirect_uris: ["http://localhost/callback", "http://127.0.0.1/callback", "http://localhost:3000/callback"], token_endpoint_auth_method: "none", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], client_id_issued_at: Math.floor(Date.now() / 1000), }, ]; for (const client of knownClients) { this.clients.set(client.client_id, client); } } getClient(clientId: string): OAuthClientInformationFull | undefined { return this.clients.get(clientId); } // RFC 7591: Dynamic Client Registration for new AI agents registerClient( clientData: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at"> & { client_id?: string; client_id_issued_at?: number; } ): OAuthClientInformationFull { const clientId = clientData.client_id || randomUUID(); const issuedAt = clientData.client_id_issued_at || Math.floor(Date.now() / 1000); const fullClient: OAuthClientInformationFull = { ...clientData, client_id: clientId, client_id_issued_at: issuedAt, token_endpoint_auth_method: clientData.token_endpoint_auth_method || "none", response_types: clientData.response_types || ["code"], grant_types: clientData.grant_types || ["authorization_code", "refresh_token"], }; this.clients.set(clientId, fullClient); return fullClient; } } 

Step 2: The OAuth 2.1 Engine (PKCE, JWTs & Token Rotation)

The official @modelcontextprotocol/sdk gives us the OAuthServerProvider interface. Our class must implement:

  1. authorize: Prepare a session and render the consent screen.
  2. challengeForAuthorizationCode: Return the PKCE challenge associated with a code.
  3. exchangeAuthorizationCode: Validate PKCE, verify the redirect URI, and issue access & refresh tokens.
  4. exchangeRefreshToken: Issue a new access token and rotate the refresh token.
  5. verifyAccessToken: Verify incoming Bearer tokens on tool calls.

Create src/auth/oauthProvider.ts:

import { randomBytes, randomUUID, createHmac, timingSafeEqual } from "node:crypto"; import type { Response, Request } from "express"; import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { TaskPulseClientStore } from "./clientStore.js"; import { renderConsentPage } from "./views/consentPage.js"; export interface UserSession { userId: string; email: string; orgId: string; } export interface AccessTokenPayload { sub: string; email: string; org_id: string; client_id: string; scopes: string[]; iss: string; iat: number; exp: number; } export class TaskPulseOAuthProvider implements OAuthServerProvider { public readonly clientsStore: OAuthRegisteredClientsStore; public readonly issuerUrl: URL; private readonly jwtSecret: string; private pendingSessions = new Map<string, any>(); private authorizationCodes = new Map<string, any>(); private refreshTokens = new Map<string, any>(); constructor(options: { issuerUrl: URL; clientsStore?: OAuthRegisteredClientsStore; jwtSecret?: string }) { this.issuerUrl = options.issuerUrl; this.clientsStore = options.clientsStore || new TaskPulseClientStore(); this.jwtSecret = options.jwtSecret || process.env.JWT_SECRET || "dev-secret-key-change-in-prod"; } // 1. Initial browser hit to /authorize async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> { const sessionId = randomUUID(); this.pendingSessions.set(sessionId, { id: sessionId, client, codeChallenge: params.codeChallenge, redirectUri: params.redirectUri, scopes: params.scopes || ["tasks:read", "tasks:write"], state: params.state, createdAt: Date.now(), }); // Cleanup session after 15 minutes setTimeout(() => this.pendingSessions.delete(sessionId), 15 * 60 * 1000); const html = renderConsentPage({ sessionId, clientName: client.client_name || client.client_id, scopes: params.scopes || ["tasks:read", "tasks:write"], }); res.status(200).type("text/html").send(html); } // 2. User submits consent form async handleConsentDecision(req: Request, res: Response): Promise<void> { const { sessionId, action, email, password } = req.body; const session = this.pendingSessions.get(sessionId); if (!session) { res.status(400).send("Session expired. Please try connecting again."); return; } this.pendingSessions.delete(sessionId); if (action === "deny") { const redirectUrl = new URL(session.redirectUri); redirectUrl.searchParams.set("error", "access_denied"); if (session.state) redirectUrl.searchParams.set("state", session.state); res.redirect(302, redirectUrl.href); return; } try { // Authenticate against your database or auth service const user = await this.authenticateUser(email, password); // Create a single-use authorization code const authCode = randomBytes(32).toString("hex"); this.authorizationCodes.set(authCode, { code: authCode, clientId: session.client.client_id, codeChallenge: session.codeChallenge, redirectUri: session.redirectUri, scopes: session.scopes, user, expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes }); const redirectUrl = new URL(session.redirectUri); redirectUrl.searchParams.set("code", authCode); if (session.state) redirectUrl.searchParams.set("state", session.state); res.redirect(302, redirectUrl.href); } catch (err: any) { const html = renderConsentPage({ sessionId, clientName: session.client.client_name, scopes: session.scopes, errorMessage: err.message || "Invalid email or password", }); this.pendingSessions.set(sessionId, session); res.status(401).type("text/html").send(html); } } // 3. Return stored PKCE challenge for verification async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> { const entry = this.authorizationCodes.get(authorizationCode); if (!entry || entry.clientId !== client.client_id || entry.expiresAt < Date.now()) { throw new InvalidGrantError("Invalid or expired authorization code"); } return entry.codeChallenge; } // 4. Exchange code for JWT access token and refresh token async exchangeAuthorizationCode( client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string ): Promise<OAuthTokens> { const entry = this.authorizationCodes.get(authorizationCode); if (!entry || entry.clientId !== client.client_id) { throw new InvalidGrantError("Invalid authorization code"); } if (redirectUri && entry.redirectUri !== redirectUri) { throw new InvalidGrantError("Redirect URI mismatch"); } Source:https://dev.to/danny_odii/building-a-production-ready-remote-mcp-server-with-oauth-20-pkce-493c

Related posts