DEV Community

Cover image for Building a Production-Ready Remote MCP Server with OAuth 2.0 & PKCE
Daniel Odii
Daniel Odii

Posted on

Building a Production-Ready Remote MCP Server with OAuth 2.0 & PKCE

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            |
+--------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode
  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"
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }

    // Crucial: Single-use code! Delete immediately
    this.authorizationCodes.delete(authorizationCode);

    const now = Math.floor(Date.now() / 1000);
    const expiresIn = 3600; // 1 hour

    const payload: AccessTokenPayload = {
      sub: entry.user.userId,
      email: entry.user.email,
      org_id: entry.user.orgId,
      client_id: client.client_id,
      scopes: entry.scopes,
      iss: this.issuerUrl.href,
      iat: now,
      exp: now + expiresIn,
    };

    const accessToken = this.signJwt(payload);
    const refreshToken = randomBytes(32).toString("hex");

    this.refreshTokens.set(refreshToken, {
      refreshToken,
      clientId: client.client_id,
      scopes: entry.scopes,
      user: entry.user,
      expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
    });

    return {
      access_token: accessToken,
      token_type: "Bearer",
      expires_in: expiresIn,
      refresh_token: refreshToken,
      scope: entry.scopes.join(" "),
    };
  }

  // 5. Refresh token rotation
  async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string): Promise<OAuthTokens> {
    const entry = this.refreshTokens.get(refreshToken);
    if (!entry || entry.clientId !== client.client_id || entry.expiresAt < Date.now()) {
      throw new InvalidGrantError("Invalid or expired refresh token");
    }

    // Rotation: invalidate previous refresh token
    this.refreshTokens.delete(refreshToken);

    const now = Math.floor(Date.now() / 1000);
    const expiresIn = 3600;

    const payload: AccessTokenPayload = {
      sub: entry.user.userId,
      email: entry.user.email,
      org_id: entry.user.orgId,
      client_id: client.client_id,
      scopes: entry.scopes,
      iss: this.issuerUrl.href,
      iat: now,
      exp: now + expiresIn,
    };

    const newAccessToken = this.signJwt(payload);
    const newRefreshToken = randomBytes(32).toString("hex");

    this.refreshTokens.set(newRefreshToken, {
      refreshToken: newRefreshToken,
      clientId: client.client_id,
      scopes: entry.scopes,
      user: entry.user,
      expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
    });

    return {
      access_token: newAccessToken,
      token_type: "Bearer",
      expires_in: expiresIn,
      refresh_token: newRefreshToken,
      scope: entry.scopes.join(" "),
    };
  }

  // 6. Verify incoming Bearer token on /sse or tool calls
  async verifyAccessToken(token: string): Promise<AuthInfo> {
    const payload = this.verifyJwt(token);
    return {
      token,
      clientId: payload.client_id,
      scopes: payload.scopes,
      expiresAt: payload.exp,
      resource: this.issuerUrl,
      extra: {
        userId: payload.sub,
        email: payload.email,
        orgId: payload.org_id,
      },
    };
  }

  // Cryptographic Helpers: Compact HMAC-SHA256 JWT
  public signJwt(payload: AccessTokenPayload): string {
    const header = { alg: "HS256", typ: "JWT" };
    const encHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
    const encPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
    const signature = createHmac("sha256", this.jwtSecret).update(`${encHeader}.${encPayload}`).digest("base64url");
    return `${encHeader}.${encPayload}.${signature}`;
  }

  public verifyJwt(token: string): AccessTokenPayload {
    const parts = token.split(".");
    if (parts.length !== 3) throw new InvalidTokenError("Malformed token");
    const [h, p, s] = parts;
    const expectedSig = createHmac("sha256", this.jwtSecret).update(`${h}.${p}`).digest("base64url");
    if (!timingSafeEqual(Buffer.from(s), Buffer.from(expectedSig))) {
      throw new InvalidTokenError("Invalid token signature");
    }
    const payload = JSON.parse(Buffer.from(p, "base64url").toString("utf-8"));
    if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
      throw new InvalidTokenError("Token expired");
    }
    return payload;
  }

  private async authenticateUser(email?: string, password?: string): Promise<UserSession> {
    // Replace with real database lookup:
    if (email === "alex@example.com" && password === "correct-horse-battery") {
      return { userId: "usr_alex_101", email, orgId: "org_engineering" };
    }
    throw new Error("Invalid email or password");
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The Interactive Consent Screen

When the client triggers the OAuth flow, the user's browser opens /authorize. They should see:

  1. Which application is asking (e.g. "Claude Desktop").
  2. The specific scopes requested.
  3. An authorization form with "Allow" and "Deny" options.

Create src/auth/views/consentPage.ts:

export function renderConsentPage(data: {
  sessionId: string;
  clientName: string;
  scopes: string[];
  errorMessage?: string;
}): string {
  const scopeDescriptions: Record<string, string> = {
    "tasks:read": "View your tasks, sprints, and project boards",
    "tasks:write": "Create and update tasks or move ticket statuses",
    "projects:admin": "Manage project settings and team members",
  };

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Authorize ${data.clientName} β€’ TaskPulse</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
    .card { background: #1e293b; padding: 32px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); width: 100%; max-width: 420px; border: 1px solid #334155; }
    h2 { margin: 0 0 8px 0; font-size: 20px; }
    p { color: #94a3b8; font-size: 14px; margin-bottom: 20px; }
    .scope-item { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 10px 14px; margin-bottom: 10px; }
    .scope-name { font-weight: 600; font-size: 13px; color: #38bdf8; }
    .scope-desc { font-size: 12px; color: #94a3b8; }
    .form-group { margin-bottom: 14px; }
    label { display: block; font-size: 12px; margin-bottom: 4px; color: #cbd5e1; }
    input { width: 100%; padding: 10px; border-radius: 6px; border: 1px solid #475569; background: #0f172a; color: white; box-sizing: border-box; }
    .btn { width: 100%; padding: 10px; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; margin-top: 8px; }
    .btn-primary { background: #0284c7; color: white; }
    .btn-secondary { background: transparent; color: #94a3b8; }
    .error { background: #ef444420; border: 1px solid #ef4444; color: #fca5a5; padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 14px; }
  </style>
</head>
<body>
  <div class="card">
    <h2>Authorize ${data.clientName}</h2>
    <p>This assistant is requesting access to your TaskPulse account.</p>

    ${data.errorMessage ? `<div class="error">${data.errorMessage}</div>` : ""}

    <div style="margin-bottom: 16px;">
      <div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; margin-bottom: 6px;">Requested Permissions</div>
      ${data.scopes.map(scope => `
        <div class="scope-item">
          <div class="scope-name">${scope}</div>
          <div class="scope-desc">${scopeDescriptions[scope] || "Access tools under this scope"}</div>
        </div>
      `).join("")}
    </div>

    <form method="POST" action="/authorize/decision">
      <input type="hidden" name="sessionId" value="${data.sessionId}" />
      <div class="form-group">
        <label>Email Address</label>
        <input type="email" name="email" required placeholder="alex@example.com" />
      </div>
      <div class="form-group">
        <label>Password</label>
        <input type="password" name="password" required />
      </div>
      <button type="submit" name="action" value="allow" class="btn btn-primary">Authorize Access</button>
      <button type="submit" name="action" value="deny" class="btn btn-secondary">Deny</button>
    </form>
  </div>
</body>
</html>`;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Express Server & MCP Transports (SSE & HTTP)

Now we wire Express, the MCP auth middleware, and the transports together.

Look how clean this is: @modelcontextprotocol/sdk gives us mcpAuthRouter and requireBearerAuth.

Create src/index.ts:

import express from "express";
import dotenv from "dotenv";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";

import { createTaskPulseServer } from "./server.js";
import { TaskPulseOAuthProvider } from "./auth/oauthProvider.js";

dotenv.config();

const app = express();
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080;
const issuerUrl = new URL(process.env.APP_URL || `http://localhost:${PORT}`);

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

// 1. Initialize our OAuth Provider
const oauthProvider = new TaskPulseOAuthProvider({ issuerUrl });

// 2. Mount standard OAuth 2.1 routes:
// /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server, /authorize, /token, /register
app.use(
  mcpAuthRouter({
    provider: oauthProvider,
    issuerUrl,
    scopesSupported: ["tasks:read", "tasks:write", "projects:admin"],
    resourceName: "TaskPulse Project & Task MCP",
  })
);

// 3. Mount the consent decision receiver
app.post("/authorize/decision", async (req, res) => {
  await oauthProvider.handleConsentDecision(req, res);
});

// 4. Create Bearer Auth Middleware (answers with 401 + WWW-Authenticate header when no token is present)
const bearerAuth = requireBearerAuth({
  verifier: oauthProvider,
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(issuerUrl),
});

// Active SSE Connections
const sseTransports = new Map<string, SSEServerTransport>();

// 5. Protected SSE Stream Endpoint
app.get("/sse", bearerAuth, async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  sseTransports.set(transport.sessionId, transport);

  res.on("close", () => {
    sseTransports.delete(transport.sessionId);
  });

  // Connect McpServer with authenticated user context!
  const server = createTaskPulseServer({ authInfo: req.auth });
  await server.connect(transport);
});

// 6. SSE Message Receiver
app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = sseTransports.get(sessionId);

  if (!transport) {
    res.status(404).json({ error: "Session not found" });
    return;
  }

  await transport.handlePostMessage(req, res, req.body);
});

app.listen(PORT, () => {
  console.log(`πŸš€ TaskPulse MCP running at ${issuerUrl.href}`);
  console.log(`πŸ”’ Protected SSE: ${issuerUrl.href}sse`);
  console.log(`πŸ“‹ Metadata: ${issuerUrl.href}.well-known/oauth-protected-resource`);
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Implementing MCP Tools, Resources & Prompts with User Context

Now comes the fun part: writing the MCP functionality!

Because our authentication middleware attaches req.auth (AuthInfo), our server factory receives:

  • authInfo.extra.userId (Who is calling?)
  • authInfo.extra.orgId (Which workspace do they belong to?)
  • authInfo.scopes (What are they allowed to do?)

Create src/server.ts:

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";

// Mock database
interface Task {
  id: string;
  orgId: string;
  title: string;
  status: "TODO" | "IN_PROGRESS" | "DONE";
  assigneeId: string;
}

const mockTasks: Task[] = [
  { id: "task_1", orgId: "org_engineering", title: "Write API documentation", status: "IN_PROGRESS", assigneeId: "usr_alex_101" },
  { id: "task_2", orgId: "org_engineering", title: "Set up CI/CD pipeline", status: "TODO", assigneeId: "usr_alex_101" },
];

export function createTaskPulseServer(options?: { authInfo?: AuthInfo }): McpServer {
  const server = new McpServer({
    name: "TaskPulse",
    version: "1.0.0",
  });

  const currentUser = options?.authInfo?.extra?.userId as string || "anonymous";
  const userOrg = options?.authInfo?.extra?.orgId as string || "default_org";
  const scopes = options?.authInfo?.scopes || [];

  // ==========================================
  // 1. TOOLS ("Do something")
  // ==========================================

  // TOOL: List Tasks (Scoped to user's organization)
  server.tool(
    "list_my_tasks",
    "List all tasks assigned to the current user in their organization.",
    {
      status: z.enum(["ALL", "TODO", "IN_PROGRESS", "DONE"]).default("ALL").optional(),
    },
    async (args) => {
      if (!scopes.includes("tasks:read")) {
        return { content: [{ type: "text", text: "Forbidden: Missing 'tasks:read' scope" }], isError: true };
      }

      const tasks = mockTasks.filter(t => t.orgId === userOrg && t.assigneeId === currentUser);
      const filtered = args.status && args.status !== "ALL" ? tasks.filter(t => t.status === args.status) : tasks;

      return {
        content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }],
      };
    }
  );

  // TOOL: Create Task (Requires 'tasks:write' scope)
  server.tool(
    "create_task",
    "Create a new task in the current user's workspace.",
    {
      title: z.string().min(3).describe("Task title"),
      status: z.enum(["TODO", "IN_PROGRESS"]).default("TODO").optional(),
    },
    async (args) => {
      if (!scopes.includes("tasks:write")) {
        return { content: [{ type: "text", text: "Forbidden: Missing 'tasks:write' scope" }], isError: true };
      }

      const newTask: Task = {
        id: `task_${Date.now()}`,
        orgId: userOrg,
        title: args.title,
        status: args.status || "TODO",
        assigneeId: currentUser,
      };
      mockTasks.push(newTask);

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            success: true,
            message: `Task created successfully by ${currentUser}`,
            task: newTask,
          }, null, 2)
        }],
      };
    }
  );

  // ==========================================
  // 2. RESOURCES ("Read context")
  // ==========================================

  // Dynamic Resource: Read project details
  server.resource(
    "project-details",
    new ResourceTemplate("taskpulse://projects/{projectId}", { list: undefined }),
    async (uri, { projectId }) => {
      const pid = Array.isArray(projectId) ? projectId[0] : projectId;
      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            projectId: pid,
            organization: userOrg,
            lead: currentUser,
            description: "Core sprint engineering workspace.",
          }, null, 2),
        }],
      };
    }
  );

  // ==========================================
  // 3. PROMPTS ("Guide the conversation")
  // ==========================================

  // Prompt: Generate standup update based on tasks
  server.prompt(
    "sprint_standup_summary",
    "Generate a structured daily standup report for the current user's active tasks.",
    {
      includeBlockers: z.string().optional().describe("Ask if there are any blockers (yes/no)"),
    },
    (args) => ({
      messages: [{
        role: "user",
        content: {
          type: "text",
          text: `You are my agile project assistant. Look at my assigned tasks in TaskPulse for user ${currentUser} and write a concise 3-bullet standup summary:
1. What I completed yesterday
2. What I'm working on today
3. Blockers (${args.includeBlockers || "none declared"}).`,
        },
      }],
    })
  );

  return server;
}
Enter fullscreen mode Exit fullscreen mode

11. Testing Your Server (Locally & in Claude/Cursor)

1. Run the Server

# In your terminal
npm run build && node --max-http-header-size=65536 dist/index.js
Enter fullscreen mode Exit fullscreen mode

2. Configure Claude Desktop

Open your configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your remote server:

{
  "mcpServers": {
    "taskpulse": {
      "url": "http://localhost:8080/sse"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Watch the Magic Happen

  1. Start Claude Desktop.
  2. Claude hits http://localhost:8080/sse and receives the 401 Unauthorized with the Protected Resource header.
  3. Claude automatically opens your browser to: http://localhost:8080/authorize?client_id=claude-desktop&...
  4. You see your dark-mode TaskPulse consent screen!
  5. Enter alex@example.com and password correct-horse-battery, then click Authorize Access.
  6. The browser redirects to http://localhost/callback?code=....
  7. Claude exchanges the code for the JWT token and connects to the SSE stream.
  8. Ask Claude: > "What tasks are assigned to me in TaskPulse?" > Claude calls list_my_tasks and replies with your tasks! >

12. Production Gotchas & Best Practices

Here are four lessons we learned the hard way when deploying MCP servers:

1. Node.js Header Size Limit

  • The Issue: With OAuth JWTs, cookies, and CORS headers, requests can exceed Node's default header limit (8KB-16KB) and throw 431 Request Header Fields Too Large.
  • The Fix: Always start Node with --max-http-header-size=65536.

2. The Issuer URL in Cloud Deployments

  • The Issue: If your server issues tokens with iss: "http://localhost:8080", but your production server lives at https://mcp.yourdomain.com, token verification will fail.
  • The Fix: Dynamically set the issuerUrl using an environment variable (APP_URL):
  const issuerUrl = new URL(process.env.APP_URL || `http://localhost:${PORT}`);
Enter fullscreen mode Exit fullscreen mode

3. Store Tokens in Redis / Database in Production

  • In this tutorial, we used in-memory Maps for sessions and tokens.
  • In production, if your cloud provider restarts your container or runs multiple replicas, in-memory tokens will be lost. Store your authorization codes and refresh tokens in Redis or PostgreSQL.

4. Always Enforce Scope Verification Inside Tools

  • Don't just check if a user is logged in. Check what they are allowed to do! Check scopes.includes("tasks:write") before mutating data.

Happy building!

Top comments (0)