---
title: "MCP Servers con OAuth 2.1: Guía para Construir el Tuyo"
description: "Cómo construir un MCP Server con autenticación OAuth 2.1 + PKCE desde cero. Guía paso a paso con código real y despliegue."
pubDate: "2026-03-27"
category: "programacion"
language: "es"
tags: ["mcp", "oauth", "claude-code", "ia", "typescript", "autenticacion", "tutorial"]
---
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:

| Tool | Scope requerido | Descripción |
|------|----------------|-------------|
| `list_tasks` | `tasks:read` | Listar tareas del usuario |
| `get_task` | `tasks:read` | Obtener detalle de una tarea |
| `create_task` | `tasks:write` | Crear nueva tarea |
| `update_task` | `tasks:write` | Actualizar 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

```json
{
  "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:

```typescript
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:

```typescript
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:

```typescript
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):

```typescript
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**:

```typescript
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:

```typescript
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.

### Authorization + Consent Screen

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

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```dockerfile
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:

```yaml
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:

```nginx
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`):

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

**Claude Desktop** (`claude_desktop_config.json`):

```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](https://twitter.com/lmmartinb). También tengo más artículos sobre [Claude Code](/blog/claude-code-consejos-dia-a-dia/) y [Agent Teams](/blog/agent-teams-claude-code/) que te pueden interesar.