MCP Servers con OAuth 2.1: Guía para Construir el Tuyo

Llevo meses construyendo MCP Servers para conectar mis aplicaciones con Claude Code, Claude Desktop y Cursor. La mayoría de tutoriales que encuentras sobre MCP son “hello world”: un servidor con un tool que suma dos números. Útil para entender el concepto, inútil para producción.

El salto real llega cuando necesitas autenticación. Tu SaaS tiene datos privados de usuarios, y necesitas que Claude acceda a ellos de forma segura, con permisos granulares y tokens que expiran. Eso es OAuth 2.1 + PKCE, y es lo que vamos a construir hoy.

Es el mismo stack que tengo corriendo en producción. Express, SDK oficial de MCP, OAuth 2.1 con PKCE, Redis y Docker.

MCP en 30 segundos

MCP es una especificación — no un framework — que define cómo Claude habla con un proceso externo que expone tools. Lo importante: no hay magia. Es JSON-RPC 2.0 sobre HTTP.

Un MCP Server expone tools (funciones que el LLM invoca) y resources (datos que puede leer). Hay dos transportes: stdio (el cliente lanza el servidor como proceso hijo, ideal para local) y StreamableHTTP (peticiones HTTP estándar, lo que necesitas para producción).

Si tu SaaS tiene una API y quieres que Claude la use directamente — sin que el usuario copie y pegue datos entre ventanas — necesitas un MCP Server.

Anatomía de un MCP Server con Auth

Vamos a construir un servidor MCP para un SaaS ficticio llamado TaskTracker — una herramienta de gestión de tareas. El servidor expondrá 4 tools:

ToolScope requeridoDescripción
list_taskstasks:readListar tareas del usuario
get_tasktasks:readObtener detalle de una tarea
create_tasktasks:writeCrear nueva tarea
update_tasktasks:writeActualizar tarea existente

El flujo completo cuando Claude quiere usar un tool es:

1. Claude descubre el servidor → GET /.well-known/oauth-authorization-server
2. Se registra como cliente   → POST /oauth/register
3. Pide autorización          → GET /oauth/authorize (redirige a consent screen)
4. El usuario aprueba         → POST /oauth/authorize/decision
5. Intercambia code por token → POST /oauth/token (con PKCE verification)
6. Usa los tools              → POST /mcp (con Bearer token)

La estructura del proyecto:

mcp-server/
├── src/
│   ├── index.ts              # Express app + rutas
│   ├── config.ts             # Variables de entorno
│   ├── types.ts              # Interfaces TypeScript
│   ├── auth/
│   │   └── middleware.ts     # Bearer token validation
│   ├── oauth/
│   │   ├── metadata.ts       # RFC 8414 discovery
│   │   ├── register.ts       # Dynamic client registration
│   │   ├── authorize.ts      # Authorization + consent
│   │   ├── token.ts          # Token exchange + refresh
│   │   ├── pkce.ts           # PKCE S256 verification
│   │   ├── scopes.ts         # Definición de scopes
│   │   └── store.ts          # Redis storage
│   └── mcp/
│       ├── server.ts         # McpServer + tools
│       └── transport.ts      # HTTP transport
├── Dockerfile
└── package.json

Setup del proyecto

{
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.1",
    "express": "^4.21.0",
    "ioredis": "^5.4.1",
    "jose": "^5.9.0",
    "uuid": "^11.0.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "typescript": "^5.7.0"
  }
}

@modelcontextprotocol/sdk es el SDK oficial de Anthropic para MCP. Incluye tanto el servidor (McpServer) como el transporte HTTP (StreamableHTTPServerTransport). jose es para firmar y verificar JWTs del consent flow. ioredis para persistir tokens en Redis.

La configuración centralizada:

export const config = {
  PORT: parseInt(process.env.PORT || '3100', 10),
  REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
  MCP_SERVER_URL: process.env.MCP_SERVER_URL!,
  API_URL: process.env.API_URL!,
  SERVICE_KEY: process.env.SERVICE_KEY!,
  JWT_SECRET: process.env.JWT_SECRET!,
  CONSENT_SCREEN_URL: process.env.CONSENT_SCREEN_URL!,
  CORS_ORIGIN: process.env.CORS_ORIGIN || '',
};

Registrar Tools

Lo primero es definir los scopes — los permisos que el usuario puede otorgar. Cada scope mapea a uno o más tools:

export const SCOPES = {
  'tasks:read': {
    description: 'View tasks',
    tools: ['list_tasks', 'get_task'],
  },
  'tasks:write': {
    description: 'Create and update tasks',
    tools: ['create_task', 'update_task'],
  },
} as const;

export type Scope = keyof typeof SCOPES;
export const ALL_SCOPES = Object.keys(SCOPES) as Scope[];

export function validateScopes(scopes: string[]): scopes is Scope[] {
  return scopes.every((s) => s in SCOPES);
}

export function hasScope(granted: string[], required: Scope): boolean {
  return granted.includes(required);
}

Ahora registramos los tools en el McpServer. Cada tool extrae la info del token desde authInfo y verifica que tenga el scope necesario:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';

function extractTokenInfo(authInfo: AuthInfo | undefined) {
  const extra = authInfo?.extra as Record<string, unknown> | undefined;
  return {
    user_id: (extra?.user_id as string) || '',
    scopes: authInfo?.scopes || [],
  };
}

export function createMcpServer(apiClient: ApiClient): McpServer {
  const server = new McpServer({
    name: 'TaskTracker',
    version: '1.0.0',
  });

  server.tool(
    'list_tasks',
    'List all tasks for the authenticated user',
    { status: { type: 'string', description: 'Filter by status' } },
    async (params, extra) => {
      const token = extractTokenInfo(extra.authInfo);
      if (!hasScope(token.scopes, 'tasks:read')) {
        return {
          content: [{ type: 'text', text: 'Insufficient scope: tasks:read required' }],
          isError: true,
        };
      }
      const tasks = await apiClient.listTasks(token.user_id, params.status);
      return {
        content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
      };
    }
  );

  return server;
}

El apiClient es un wrapper HTTP simple que habla con tu API backend usando una clave interna de servicio (service-to-service auth, sin exponer credenciales del usuario):

export class ApiClient {
  constructor(private apiUrl: string, private serviceKey: string) {}

  private async request(method: string, path: string, userId: string) {
    const response = await fetch(`${this.apiUrl}${path}`, {
      method,
      headers: {
        'X-Service-Key': this.serviceKey,
        'X-User-Id': userId,
        'Content-Type': 'application/json',
      },
    });
    return response.json();
  }

  async listTasks(userId: string, status?: string) {
    const query = status ? `?status=${status}` : '';
    return this.request('GET', `/api/v1/tasks${query}`, userId);
  }
}

OAuth 2.1 + PKCE paso a paso

Sin auth, tu MCP server es un endpoint público que cualquiera puede usar. Con OAuth 2.1, el usuario controla exactamente qué permisos otorga.

Discovery: OAuth Metadata (RFC 8414)

El primer paso que hace cualquier cliente MCP es descubrir qué endpoints tiene tu servidor. Esto se hace con el well-known endpoint:

import { Router } from 'express';

export const metadataRouter = Router();

metadataRouter.get('/.well-known/oauth-authorization-server', (_req, res) => {
  res.json({
    issuer: config.MCP_SERVER_URL,
    authorization_endpoint: `${config.MCP_SERVER_URL}/oauth/authorize`,
    token_endpoint: `${config.MCP_SERVER_URL}/oauth/token`,
    registration_endpoint: `${config.MCP_SERVER_URL}/oauth/register`,
    scopes_supported: ALL_SCOPES,
    response_types_supported: ['code'],
    grant_types_supported: ['authorization_code', 'refresh_token'],
    token_endpoint_auth_methods_supported: ['none'],
    code_challenge_methods_supported: ['S256'],
  });
});

Cosas importantes:

  • token_endpoint_auth_methods_supported: ['none'] — Los clientes MCP son “public clients” (no pueden guardar un secret), así que no hay client secret. La seguridad viene de PKCE.
  • code_challenge_methods_supported: ['S256'] — Solo soportamos SHA-256 para PKCE. plain no es seguro.

Dynamic Client Registration

Cuando Claude o Cursor se conectan por primera vez, necesitan registrarse como cliente OAuth:

registerRouter.post('/oauth/register', async (req: Request, res: Response) => {
  const ip = req.ip || req.socket.remoteAddress || 'unknown';
  if (!checkRateLimit(ip)) {
    res.status(429).json({ error: 'rate_limit_exceeded' });
    return;
  }

  const { client_name, redirect_uris, grant_types, response_types } = req.body;

  if (!client_name || !redirect_uris?.length) {
    res.status(400).json({ error: 'invalid_client_metadata' });
    return;
  }

  const client = {
    client_id: uuidv4(),
    client_name,
    redirect_uris,
    grant_types: grant_types || ['authorization_code', 'refresh_token'],
    response_types: response_types || ['code'],
    token_endpoint_auth_method: 'none',
    created_at: Date.now(),
  };

  await storeClient(client);
  res.status(201).json(client);
});

Rate limit: 5 registros por minuto por IP. Sin esto, cualquiera podría llenar tu Redis de basura.

El flujo de autorización es el corazón de OAuth. Cuando el usuario quiere conectar Claude con tu app:

authorizeRouter.get('/oauth/authorize', async (req: Request, res: Response) => {
  const {
    client_id, redirect_uri, response_type,
    scope, state, code_challenge, code_challenge_method
  } = req.query as Record<string, string>;

  if (response_type !== 'code') {
    res.status(400).json({ error: 'unsupported_response_type' });
    return;
  }

  if (!client_id || !redirect_uri || !code_challenge || code_challenge_method !== 'S256') {
    res.status(400).json({ error: 'invalid_request' });
    return;
  }

  const client = await getClient(client_id);
  if (!client || !client.redirect_uris.includes(redirect_uri)) {
    res.status(400).json({ error: 'invalid_client' });
    return;
  }

  const scopes = scope ? scope.split(' ') : [];
  if (scopes.length > 0 && !validateScopes(scopes)) {
    res.status(400).json({ error: 'invalid_scope' });
    return;
  }

  const consentToken = await new jose.SignJWT({
    client_id, redirect_uri, scopes, state,
    code_challenge, code_challenge_method,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('10m')
    .setIssuedAt()
    .sign(new TextEncoder().encode(config.JWT_SECRET));

  const consentUrl = new URL(config.CONSENT_SCREEN_URL);
  consentUrl.searchParams.set('consent_token', consentToken);
  res.redirect(consentUrl.toString());
});

El truco aquí es el JWT como transporte. En vez de guardar el estado de la autorización en Redis (que es lo habitual), empaquetamos todo en un JWT firmado con expiración de 10 minutos. La consent screen (tu frontend Vue/React) decodifica el JWT, muestra los scopes al usuario, y envía la decisión de vuelta.

Cuando el usuario aprueba:

authorizeRouter.post('/oauth/authorize/decision', async (req: Request, res: Response) => {
  const { consent_token, approved, user_id, scopes } = req.body;

  let payload;
  try {
    const { payload: jwt } = await jose.jwtVerify(
      consent_token,
      new TextEncoder().encode(config.JWT_SECRET),
      { algorithms: ['HS256'] }
    );
    payload = jwt;
  } catch {
    res.status(400).json({ error: 'invalid_request' });
    return;
  }

  if (!approved) {
    const redirectUrl = new URL(payload.redirect_uri);
    redirectUrl.searchParams.set('error', 'access_denied');
    res.json({ redirect_uri: redirectUrl.toString() });
    return;
  }

  const code = generateToken();
  const authCode = {
    code,
    client_id: payload.client_id,
    redirect_uri: payload.redirect_uri,
    user_id,
    scopes: scopes || payload.scopes,
    code_challenge: payload.code_challenge,
    code_challenge_method: payload.code_challenge_method,
    created_at: Date.now(),
    used: false,
  };

  await storeAuthorizationCode(authCode);

  const redirectUrl = new URL(payload.redirect_uri);
  redirectUrl.searchParams.set('code', code);
  if (payload.state) redirectUrl.searchParams.set('state', payload.state);
  res.json({ redirect_uri: redirectUrl.toString() });
});

El authorization code se guarda en Redis con TTL de 5 minutos y un flag used para prevenir replay attacks.

PKCE: Proof Key for Code Exchange

PKCE es lo que protege a los public clients (que no tienen secret). El concepto es simple:

  1. El cliente genera un code_verifier aleatorio (43-128 caracteres)
  2. Calcula code_challenge = BASE64URL(SHA256(code_verifier))
  3. Envía el code_challenge en el authorize request
  4. Envía el code_verifier original en el token request
  5. El servidor verifica que SHA256(code_verifier) === code_challenge

La implementación es de 4 líneas:

import crypto from 'crypto';

export function verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean {
  const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
  return hash === codeChallenge;
}

Si alguien intercepta el code_challenge, no puede revertirlo a code_verifier — SHA-256 es unidireccional. Sin code_verifier, el authorization code no sirve de nada.

Token Exchange

El momento donde todo se junta. El cliente envía el authorization code + code_verifier, y recibe access + refresh tokens:

async function handleAuthorizationCode(req: Request, res: Response) {
  const { code, client_id, redirect_uri, code_verifier } = req.body;

  if (!code || !client_id || !redirect_uri || !code_verifier) {
    res.status(400).json({ error: 'invalid_request' });
    return;
  }

  const authCode = await getAuthorizationCode(code);
  if (!authCode || authCode.used) {
    res.status(400).json({ error: 'invalid_grant' });
    return;
  }

  if (authCode.client_id !== client_id || authCode.redirect_uri !== redirect_uri) {
    res.status(400).json({ error: 'invalid_grant' });
    return;
  }

  if (!verifyCodeChallenge(code_verifier, authCode.code_challenge)) {
    res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
    return;
  }

  await markCodeUsed(hashToken(code));

  const rawAccessToken = generateToken();
  const rawRefreshToken = generateToken();
  const now = Date.now();

  const accessToken = {
    token_hash: hashToken(rawAccessToken),
    client_id,
    user_id: authCode.user_id,
    scopes: authCode.scopes,
    created_at: now,
    expires_at: now + 3600 * 1000,
  };

  const refreshToken = {
    token_hash: hashToken(rawRefreshToken),
    client_id,
    user_id: authCode.user_id,
    scopes: authCode.scopes,
    access_token_hash: accessToken.token_hash,
    created_at: now,
    expires_at: now + 2592000 * 1000,
    used: false,
  };

  await storeAccessToken(accessToken, rawAccessToken);
  await storeRefreshToken(refreshToken, rawRefreshToken);

  res.json({
    access_token: rawAccessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    refresh_token: rawRefreshToken,
    scope: authCode.scopes.join(' '),
  });
}

Detalle importante: los tokens se almacenan hasheados en Redis (SHA-256(token) → datos). El token raw solo se envía al cliente una vez. Si alguien accede a tu Redis, no puede usar los tokens directamente.

TTLs: access token 1 hora, refresh token 30 días.

Bearer Auth Middleware

Cada request al endpoint MCP pasa por este middleware que valida el Bearer token:

export async function bearerAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401)
      .set('WWW-Authenticate', `Bearer resource_metadata="${config.MCP_SERVER_URL}/.well-known/oauth-protected-resource"`)
      .json({ error: 'unauthorized' });
    return;
  }

  const rawToken = authHeader.slice(7);
  const token = await getAccessToken(rawToken);

  if (!token || Date.now() > token.expires_at) {
    res.status(401).json({ error: 'invalid_token' });
    return;
  }

  req.auth = {
    token: rawToken,
    clientId: token.client_id,
    scopes: token.scopes,
    expiresAt: Math.floor(token.expires_at / 1000),
    extra: { user_id: token.user_id },
  };

  next();
}

El header WWW-Authenticate es importante: le dice al cliente dónde encontrar los metadatos del recurso protegido, para que pueda iniciar el flujo OAuth automáticamente.

Transporte HTTP

El SDK de MCP incluye StreamableHTTPServerTransport que gestiona las sesiones. Cada conexión de un cliente crea una sesión con un UUID único:

import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { Router } from 'express';
import { randomUUID } from 'crypto';

const sessions = new Map<string, StreamableHTTPServerTransport>();

export function createMcpRouter(serverFactory: () => McpServer): Router {
  const router = Router();

  router.post('/', async (req, res) => {
    const sessionId = req.headers['mcp-session-id'] as string | undefined;

    if (sessionId && sessions.has(sessionId)) {
      const transport = sessions.get(sessionId)!;
      await transport.handleRequest(req, res, req.body);
      return;
    }

    if (!sessionId && isInitializeRequest(req.body)) {
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: () => randomUUID(),
        onsessioninitialized: (id) => { sessions.set(id, transport); },
      });

      transport.onclose = () => {
        const entry = [...sessions.entries()].find(([, t]) => t === transport);
        if (entry) sessions.delete(entry[0]);
      };

      const server = serverFactory();
      await server.connect(transport);
      await transport.handleRequest(req, res, req.body);
      return;
    }

    res.status(400).json({ error: 'Bad request' });
  });

  router.get('/', async (req, res) => {
    const sessionId = req.headers['mcp-session-id'] as string;
    if (sessionId && sessions.has(sessionId)) {
      await sessions.get(sessionId)!.handleRequest(req, res);
    }
  });

  router.delete('/', async (req, res) => {
    const sessionId = req.headers['mcp-session-id'] as string;
    if (sessionId && sessions.has(sessionId)) {
      await sessions.get(sessionId)!.close();
      sessions.delete(sessionId);
    }
    res.status(200).end();
  });

  return router;
}

POST sin session ID + initialize crea sesión nueva. POST con session ID reutiliza la existente. GET es para SSE. DELETE limpia.

Wiring: unir todas las piezas

El index.ts conecta todo:

import express from 'express';
import { metadataRouter } from './oauth/metadata';
import { registerRouter } from './oauth/register';
import { authorizeRouter } from './oauth/authorize';
import { tokenRouter } from './oauth/token';
import { bearerAuth } from './auth/middleware';
import { ApiClient } from './auth/api-client';
import { createMcpServer } from './mcp/server';
import { createMcpRouter } from './mcp/transport';

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

app.get('/health', (_req, res) => res.json({ status: 'ok' }));

app.use(metadataRouter);
app.use(registerRouter);
app.use(authorizeRouter);
app.use(tokenRouter);

const apiClient = new ApiClient(config.API_URL, config.SERVICE_KEY);
const mcpRouter = createMcpRouter(() => createMcpServer(apiClient));
app.use('/mcp', bearerAuth, mcpRouter);

app.listen(config.PORT, () => {
  console.log(`MCP Server running on port ${config.PORT}`);
});

Fíjate en la línea clave: app.use('/mcp', bearerAuth, mcpRouter). Los endpoints OAuth (/oauth/*) son públicos. El endpoint MCP (/mcp) está protegido por el middleware Bearer. Separación limpia.

Despliegue

Un Dockerfile multi-stage para producción:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3100
CMD ["node", "dist/index.js"]

Y el docker-compose con Redis:

services:
  mcp:
    build: .
    ports:
      - "3100:3100"
    environment:
      PORT: "3100"
      REDIS_URL: "redis://redis:6379/1"
      MCP_SERVER_URL: "https://mcp.tasktracker.dev"
      API_URL: "http://api:8080"
      SERVICE_KEY: "${SERVICE_KEY}"
      JWT_SECRET: "${JWT_SECRET}"
      CONSENT_SCREEN_URL: "https://app.tasktracker.dev/oauth/consent"
    depends_on:
      - redis
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3100/health"]
      interval: 30s

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  redis-data:

En Nginx, un reverse proxy simple:

server {
    server_name mcp.tasktracker.dev;

    location / {
        proxy_pass http://localhost:3100;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

Conectar con Claude Desktop y Claude Code

Una vez desplegado, conectar es trivial.

Claude Code (~/.claude/settings.json):

{
  "mcpServers": {
    "tasktracker": {
      "url": "https://mcp.tasktracker.dev/mcp"
    }
  }
}

Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "tasktracker": {
      "url": "https://mcp.tasktracker.dev/mcp"
    }
  }
}

La primera vez que Claude intente usar un tool, el flujo OAuth se dispara automáticamente: se abre el navegador, el usuario ve la consent screen con los scopes, aprueba, y Claude recibe el token. A partir de ahí, todo es transparente.

Para qué sirve todo esto

Lo que tienes al final es un servidor donde cada request está firmado, cada scope está auditado, y los tokens nunca tocan Redis en claro. Es la diferencia entre “funciona” y “funciona en producción”.

Llevo meses con este stack desplegado. La primera vez que un usuario conectó Claude con su cuenta y le pidió analizar sus datos sin abrir la app, entendí para qué sirve esto. No es una demo. Es un cambio de flujo de trabajo.


P.D. Si estás montando un MCP Server o tienes dudas sobre el flujo OAuth, me encuentras en Twitter como @lmmartinb. También tengo más artículos sobre Claude Code y Agent Teams que te pueden interesar.

Luis Miguel Martín
Luis Miguel Martín

CTO en LCApps. Escribo sobre Claude Code, MCP, agentes IA y arquitectura de software real.

💡

¿Te ha gustado este artículo?

Explora más artículos sobre desarrollo, buenas prácticas y herramientas.