If you have experimented with the Model Context Protocol (MCP) recently, chances are you followed a quick tutorial that looked something like this:
- Create a quick
server.tsfile using@modelcontextprotocol/sdk. - Add a tool that adds two numbers or fetches a mock weather report.
- Wire it into your desktop AI client (like Claude Desktop or Cursor) via
stdio(standard input/output) running locally on your laptop. - Hardcode your personal API key or database string in a
.envfile.
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
- The "Localhost Trap" vs. Remote Cloud MCP
- The 3 Pillars of MCP: Tools, Resources, and Prompts
- OAuth 2.0 & MCP: Demystifying the Concepts
- The Complete MCP OAuth Handshake (Visual Diagram)
- Project Architecture Blueprint
- Step 1: Client Store & Dynamic Registration
- Step 2: The OAuth 2.1 Engine (PKCE, JWTs & Token Rotation)
- Step 3: The Interactive Consent Screen
- Step 4: Express Server & MCP Transports (SSE & HTTP)
- Step 5: Implementing MCP Tools, Resources & Prompts with User Context
- Testing Your Server (Locally & in Claude/Cursor)
- Production Gotchas & Best Practices
- 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 (
/ssefor 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).
-
Tools (
server.tool(...)): Interactive functions with typed schemas (using Zod). The AI decides when to call them based on the conversation. -
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. -
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:
- The AI client creates a random secret string (
code_verifier). - It hashes that string with SHA-256 to create a
code_challenge. - It sends the
code_challengewhen launching the browser login. - Once the user approves, the client exchanges the temporary code for tokens by presenting the original
code_verifier. - The server hashes the verifier. If it matches the original challenge, access is granted!
- The AI client creates a random secret string (
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
/authorizeURL,/tokenURL, 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 /registerwith its name and redirect URI, and the server generates aclient_idautomatically.
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:
-
authorize: Prepare a session and render the consent screen. -
challengeForAuthorizationCode: Return the PKCE challenge associated with a code. -
exchangeAuthorizationCode: Validate PKCE, verify the redirect URI, and issue access & refresh tokens. -
exchangeRefreshToken: Issue a new access token and rotate the refresh token. -
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");
}
}
Step 3: The Interactive Consent Screen
When the client triggers the OAuth flow, the user's browser opens /authorize. They should see:
- Which application is asking (e.g. "Claude Desktop").
- The specific scopes requested.
- 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>`;
}
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`);
});
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;
}
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
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"
}
}
}
3. Watch the Magic Happen
- Start Claude Desktop.
- Claude hits
http://localhost:8080/sseand receives the401 Unauthorizedwith the Protected Resource header. - Claude automatically opens your browser to:
http://localhost:8080/authorize?client_id=claude-desktop&... - You see your dark-mode TaskPulse consent screen!
- Enter
alex@example.comand passwordcorrect-horse-battery, then click Authorize Access. - The browser redirects to
http://localhost/callback?code=.... - Claude exchanges the code for the JWT token and connects to the SSE stream.
- Ask Claude:
> "What tasks are assigned to me in TaskPulse?"
> Claude calls
list_my_tasksand 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 athttps://mcp.yourdomain.com, token verification will fail. -
The Fix: Dynamically set the
issuerUrlusing an environment variable (APP_URL):
const issuerUrl = new URL(process.env.APP_URL || `http://localhost:${PORT}`);
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)