Token导航 LogoToken导航TokenDH.com
Remote Connector Claude logo
安全风控stdio官方级别未说明来源级核验

Remote Connector Claude

MCP Server

@modelcontextprotocol/inspector

一个安全的远程MCP服务器,允许Claude Desktop通过OAuth 2.1和PKCE安全地连接并使用自定义工具。

工具数

2

提示词数

0

GitHub Stars

0

资源数

0
远程服务TypeScriptClaudeClaude DesktopClaude

安装说明

本站只整理中文说明和来源信息,不托管安装包,也不代用户安装。

作者 / 组织

leonobitech

提供方

leonobitech

最后核验

2026/5/17 20:20

运行时

Node.js

快速接入

先看主来源和安装命令,再打开仓库或文档;下面只保留这个条目的关键接入事实。

命令预览

npx @modelcontextprotocol/inspector

详细介绍

🤖 Remote MCP Connector for Claude Desktop

Este documento explica cómo funciona el servidor MCP (Model Context Protocol) con autenticación OAuth que tenemos en este repositorio. Es una guía educativa para entender cada componente y cómo interactúan.

FROM LOCALHOST TO PRODUCTION — BUILT LIKE A HACKER


📚 Tabla de Contenidos

  1. Visión General del Sistema
  2. Conceptos Fundamentales
  3. Flujo Completo de Conexión
  4. Arquitectura del Código
  5. Componentes Clave
  6. Protocolos y Estándares
  7. Seguridad
  8. Troubleshooting

🎯 Visión General del Sistema

¿Qué es esto?

Es un servidor remoto MCP que permite a Claude Desktop conectarse de forma segura para usar herramientas personalizadas. Piensa en ello como una "extensión segura" para Claude Desktop.

┌─────────────────┐         OAuth 2.1 + PKCE         ┌──────────────────┐
│                 │ ◄──────────────────────────────► │                  │
│ Claude Desktop  │                                  │  Tu Servidor MCP │
│                 │      MCP Protocol 2025-06-18     │  (Este proyecto) │
│                 │ ◄──────────────────────────────► │                  │
└─────────────────┘         JSON-RPC over HTTP       └──────────────────┘
                                                              │
                                                              │
                                                              ▼
                                                     ┌─────────────────┐
                                                     │  Redis + Prisma │
                                                     │  (Estado/Tokens)│
                                                     └─────────────────┘

¿Por qué es especial?

  1. Remoto: Claude Desktop se conecta por internet (no local)
  2. Seguro: Usa OAuth 2.1 con PKCE (mismo estándar que Google/GitHub)
  3. Productivo: Soporta múltiples usuarios, sesiones, y tokens con expiración
  4. Estándar: Cumple con RFCs oficiales (RFC 7591, RFC 9728, etc.)

🧠 Conceptos Fundamentales

1. MCP (Model Context Protocol)

¿Qué es? Un protocolo creado por Anthropic para que los LLMs puedan usar herramientas externas.

¿Cómo funciona?

  • Claude Desktop (cliente) envía mensajes JSON-RPC
  • Tu servidor (servidor MCP) responde con datos o ejecuta acciones
  • Usa HTTP como transporte (no WebSocket, no SSE en la versión actual)

Ejemplo de mensaje MCP:

{
  "jsonrpc": "2.0",
  "method": "tools/list",
  "params": {},
  "id": 1
}

Respuesta:

{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "ping",
        "description": "Herramienta de diagnóstico...",
        "inputSchema": { ... }
      }
    ]
  },
  "id": 1
}

2. OAuth 2.1 con PKCE

¿Qué es OAuth? Un estándar de autorización. Permite que Claude Desktop acceda a tu servidor en tu nombre sin compartir contraseñas.

¿Qué es PKCE? (Proof Key for Code Exchange)

  • Protección extra contra ataques
  • Usa un "secret" generado dinámicamente que solo el cliente conoce
  • Evita que alguien robe el authorization code en tránsito

Flujo simplificado:

1. Claude genera: code_verifier (secreto random)
2. Claude calcula: code_challenge = SHA256(code_verifier)
3. Claude pide autorización con code_challenge
4. Usuario aprueba → Servidor da authorization_code
5. Claude intercambia code + code_verifier por access_token
6. Servidor verifica: SHA256(code_verifier) == code_challenge ✓
7. Claude usa access_token para llamar al MCP

3. JWT (JSON Web Tokens)

¿Qué es? Un formato de token que contiene información firmada.

Estructura:

eyJhbGci... . eyJzdWIi... . JcFNGpQ...
   ↑            ↑            ↑
 Header       Payload     Signature

Header (algoritmo):

{
  "alg": "RS256",
  "kid": "claude-key-1",
  "typ": "JWT"
}

Payload (datos):

{
  "sub": "claude-user", // Usuario
  "scope": "claude.app", // Permisos
  "iat": 1760570127, // Fecha emisión
  "exp": 1760570427, // Fecha expiración (5 min)
  "iss": "https://...", // Emisor
  "aud": "claude-mcp" // Audiencia
}

Signature (firma RSA):

RSA-SHA256(
  base64(header) + "." + base64(payload),
  privateKey
)

¿Por qué RSA y no HMAC?

  • RSA = clave pública/privada
  • Tu servidor firma con clave privada
  • Otros pueden verificar con clave pública
  • Imposible falsificar sin la clave privada

🔄 Flujo Completo de Conexión

Fase 1: Discovery (Descubrimiento)

Claude Desktop descubre las capacidades del servidor:

1. HEAD /.well-known/anthropic/manifest.json
   → Verifica que existe

2. GET /.well-known/anthropic/manifest.json
   ← Responde con:
   {
     "oauth": { ... },
     "api": {
       "type": "http",
       "url": "https://connector.leonobitech.com/mcp"
     }
   }

3. GET /.well-known/oauth-authorization-server
   ← Responde con:
   {
     "authorization_endpoint": "https://.../oauth/authorize",
     "token_endpoint": "https://.../oauth/token",
     "registration_endpoint": "https://.../oauth/register"
   }

4. GET /.well-known/oauth-protected-resource
   ← Responde con:
   {
     "resource": "https://.../mcp",
     "authorization_servers": ["https://..."]
   }

Fase 2: Dynamic Client Registration

Claude Desktop se auto-registra:

POST /oauth/register
Content-Type: application/json

{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "scope": "claude.app"
}

← Respuesta:
{
  "client_id": "claude-mcp",
  "client_secret": "...",
  "scope": "claude.app claudeai",
  "token_endpoint_auth_method": "client_secret_post"
}

Nota: Tu servidor devuelve client_id/secret estáticos (configurados en .env). En producción real, generarías dinámicamente para cada cliente.

Fase 3: Authorization Code Flow + PKCE

1. Claude genera:
   code_verifier = random(43-128 chars)
   code_challenge = base64url(SHA256(code_verifier))
   state = random() // Anti-CSRF

2. Claude redirige al usuario:
   GET /oauth/authorize?
     response_type=code&
     client_id=claude-mcp&
     redirect_uri=https://claude.ai/api/mcp/auth_callback&
     scope=claude.app&
     code_challenge=uxucS0f...&
     code_challenge_method=S256&
     state=wUV2Tuh...

3. Tu servidor:
   - Muestra página de login/consent (actualmente auto-aprueba)
   - Genera authorization_code
   - Guarda en Redis: {code_challenge, redirect_uri, scope, userId}
   - Redirige: https://claude.ai/api/mcp/auth_callback?code=ABC&state=wUV2Tuh...

4. Claude verifica state ✓
   POST /oauth/token
   {
     grant_type: "authorization_code",
     code: "ABC",
     code_verifier: "original_verifier",
     redirect_uri: "https://claude.ai/api/mcp/auth_callback",
     client_id: "claude-mcp",
     client_secret: "..."
   }

5. Tu servidor verifica:
   ✓ code existe en Redis
   ✓ SHA256(code_verifier) == code_challenge guardado
   ✓ redirect_uri coincide
   ✓ client credentials válidos

   Genera:
   - access_token (JWT, expira en 5min)
   - refresh_token (guardado en Redis, expira en 7 días)

   Responde:
   {
     "access_token": "eyJhbG...",
     "token_type": "Bearer",
     "expires_in": 300,
     "refresh_token": "...",
     "scope": "claude.app"
   }

Fase 4: Conexión MCP

Ahora Claude Desktop tiene el access_token y puede usar el MCP:

1. POST /.well-known/anthropic/manifest.json
   Authorization: Bearer eyJhbG...
   {
     "method": "initialize",
     "params": {
       "protocolVersion": "2025-06-18",
       "capabilities": {},
       "clientInfo": {"name": "claude-ai", "version": "0.1.0"}
     },
     "jsonrpc": "2.0",
     "id": 0
   }

   → Tu servidor redirige (307) a: /mcp

2. POST /mcp
   Authorization: Bearer eyJhbG...
   (mismo body)

   Tu servidor:
   - Verifica JWT (firma RSA)
   - Extrae userId del token
   - Crea nueva sesión MCP con UUID
   - Responde:
   {
     "jsonrpc": "2.0",
     "result": {
       "protocolVersion": "2025-06-18",
       "capabilities": {"tools": {}},
       "serverInfo": {
         "name": "leonobitech-claude-mcp-server",
         "version": "0.1.0"
       }
     },
     "id": 0
   }
   Headers:
   Mcp-Session-Id: 28c85319-d08a-4063-b30c-34f24b92c20c

3. POST /mcp
   Authorization: Bearer eyJhbG...
   Mcp-Session-Id: 28c85319-d08a-4063-b30c-34f24b92c20c
   {
     "method": "tools/list",
     "params": {},
     "jsonrpc": "2.0",
     "id": 1
   }

   ← Responde lista de herramientas (ping, get_user_info)

4. POST /mcp
   {
     "method": "tools/call",
     "params": {
       "name": "ping",
       "arguments": {"message": "hello"}
     },
     "jsonrpc": "2.0",
     "id": 2
   }

   ← Responde:
   {
     "jsonrpc": "2.0",
     "result": {
       "content": [{"type": "text", "text": "🏓 hello"}]
     },
     "id": 2
   }

🏗️ Arquitectura del Código

Estructura de Directorios

claude-oauth/
├── src/
│   ├── config/          # Configuración (env, Redis, RSA keys)
│   │   ├── env.ts       # Variables de entorno validadas con Zod
│   │   └── redis.ts     # Cliente Redis configurado
│   │
│   ├── lib/             # Librerías compartidas
│   │   ├── auth.ts      # Funciones JWT (sign, verify)
│   │   ├── logger.ts    # Logger estructurado (pino)
│   │   └── redis.ts     # Funciones helper de Redis
│   │
│   ├── routes/          # Endpoints HTTP
│   │   ├── oauth.ts     # /oauth/authorize, /oauth/token, /oauth/register
│   │   ├── mcp-http.ts  # /mcp (MCP JSON-RPC handler)
│   │   ├── well-known/  # /.well-known/* (discovery endpoints)
│   │   └── health.ts    # /healthz
│   │
│   ├── middlewares/     # Middlewares Express
│   │   └── (en oauth.ts y mcp-http.ts)
│   │
│   ├── types/           # TypeScript types
│   │   └── (inline en cada archivo)
│   │
│   └── index.ts         # Entry point (Express app)
│
├── keys/                # Claves RSA (NO en git)
│   ├── privateKey.pem
│   ├── publicKey.pem
│   └── jwks.json
│
├── .env                 # Variables de entorno (NO en git)
├── Dockerfile           # Imagen Docker
├── docker-compose.yml   # Orquestación (si usas)
└── package.json

Stack Tecnológico

// Framework HTTP
Express 5

// Base de datos (opcional para users/sessions)
Prisma + PostgreSQL

// Cache / Tokens
Redis (DB lógica 5)

// Autenticación
- jsonwebtoken (JWT sign/verify)
- crypto (RSA, random, SHA256)

// Validación
Zod (schemas de request/response)

// Logging
Pino (JSON structured logging)

// MCP SDK
@modelcontextprotocol/sdk

🔑 Componentes Clave

1. /src/routes/oauth.ts

Responsabilidad: Implementa el servidor de autorización OAuth 2.1

Endpoint: POST /oauth/register

// Dynamic Client Registration (RFC 7591)
oauthRouter.post("/register", (req, res) => {
  // 1. Validar request body con Zod
  const { redirect_uris, scope, grant_types } = req.body;

  // 2. Verificar que redirect_uri está en whitelist
  const allowedRedirectUris = [
    "https://claude.ai/api/mcp/auth_callback",
    // ... otros
  ];

  // 3. Devolver client credentials (estáticos en tu caso)
  res.status(201).json({
    client_id: env.CLIENT_ID,
    client_secret: env.CLIENT_SECRET,
    scope: env.SCOPES,
    // ... más metadata
  });
});

¿Por qué estático? Simplifica. En producción multicliente, generarías client_id único por cada registro y guardarías en DB.

Endpoint: GET /oauth/authorize

oauthRouter.get("/authorize", async (req, res) => {
  // 1. Validar query params
  const {
    response_type, // Debe ser "code"
    client_id,
    redirect_uri,
    scope,
    state, // Anti-CSRF token
    code_challenge, // PKCE: hash del verifier
    code_challenge_method, // Debe ser "S256"
  } = req.query;

  // 2. Verificar client_id y redirect_uri

  // 3. (Aquí deberías mostrar UI de login/consent)
  //    Actualmente auto-aprueba para userId="claude-user"

  // 4. Generar authorization code
  const authCode = randomBytes(32).toString("base64url");

  // 5. Guardar en Redis con TTL
  await redis.setex(
    `authcode:${authCode}`,
    180, // 3 minutos
    JSON.stringify({
      userId: "claude-user",
      clientId: client_id,
      redirectUri: redirect_uri,
      scope,
      codeChallenge: code_challenge,
      codeChallengeMethod: code_challenge_method,
    })
  );

  // 6. Redirigir al redirect_uri con code
  const redirectUrl = new URL(redirect_uri);
  redirectUrl.searchParams.set("code", authCode);
  redirectUrl.searchParams.set("state", state);

  res.redirect(redirectUrl.toString());
});

Endpoint: POST /oauth/token

oauthRouter.post("/token", async (req, res) => {
  const { grant_type } = req.body;

  if (grant_type === "authorization_code") {
    const { code, code_verifier, client_id, client_secret, redirect_uri } =
      req.body;

    // 1. Recuperar datos del authorization code de Redis
    const authData = await redis.get(`authcode:${code}`);
    if (!authData) {
      return res.status(400).json({ error: "invalid_grant" });
    }

    const data = JSON.parse(authData);

    // 2. Verificar PKCE
    const challengeFromVerifier = crypto
      .createHash("sha256")
      .update(code_verifier)
      .digest("base64url");

    if (challengeFromVerifier !== data.codeChallenge) {
      return res.status(400).json({ error: "invalid_grant" });
    }

    // 3. Verificar client credentials
    if (client_id !== env.CLIENT_ID || client_secret !== env.CLIENT_SECRET) {
      return res.status(401).json({ error: "invalid_client" });
    }

    // 4. Generar access token (JWT)
    const accessToken = signAccessToken({
      sub: data.userId,
      scope: data.scope,
      aud: client_id,
    });

    // 5. Generar refresh token
    const refreshToken = randomBytes(32).toString("base64url");
    await redis.setex(
      `refresh:${refreshToken}`,
      604800, // 7 días
      JSON.stringify({ userId: data.userId, scope: data.scope })
    );

    // 6. Guardar access token en Redis para poder revocarlo
    const jti = extractJti(accessToken);
    await redis.setex(
      `token:${jti}`,
      300, // 5 minutos (TTL del access token)
      data.userId
    );

    // 7. Borrar authorization code (uso único)
    await redis.del(`authcode:${code}`);

    // 8. Responder
    res.json({
      token_type: "Bearer",
      access_token: accessToken,
      expires_in: 300,
      refresh_token: refreshToken,
      scope: data.scope,
    });
  } else if (grant_type === "refresh_token") {
    // Similar pero usando refresh_token en lugar de code
    // ...
  }
});

2. /src/routes/mcp-http.ts

Responsabilidad: Implementa el servidor MCP usando HTTP transport

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

// Almacén de sesiones MCP en memoria
const transports = new Map();

// Middleware de autenticación
async function authenticateMcpRequest(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "invalid_request" });
  }

  const token = authHeader.slice(7);

  try {
    // Verificar JWT (firma RSA)
    const payload = await verifyAccessToken(token);

    // Verificar que el token existe en Redis (no revocado)
    const jti = payload.jti;
    const exists = await redis.exists(`token:${jti}`);
    if (!exists) {
      return res.status(401).json({ error: "invalid_token" });
    }

    // Verificar scopes
    const tokenScopes = payload.scope.split(" ");
    const hasRequiredScopes = requiredScopes.every((s) =>
      tokenScopes.includes(s)
    );
    if (!hasRequiredScopes) {
      return res.status(403).json({ error: "insufficient_scope" });
    }

    // Guardar userId en res.locals para usar en handlers
    res.locals.auth = {
      subject: payload.sub,
      scope: payload.scope,
    };

    next();
  } catch (error) {
    return res.status(401).json({ error: "invalid_token" });
  }
}

// Función para crear servidor MCP con herramientas
function createMcpServer(userId: string) {
  const server = new Server(
    {
      name: "leonobitech-claude-mcp-server",
      version: "0.1.0",
    },
    {
      capabilities: {
        tools: {}, // Soporta herramientas
      },
    }
  );

  // Handler: listar herramientas
  server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
      tools: [
        {
          name: "ping",
          description: "Returns a pong message to test connectivity",
          inputSchema: {
            type: "object",
            properties: {
              message: { type: "string" },
            },
          },
        },
        {
          name: "get_user_info",
          description: "Returns information about the authenticated user",
          inputSchema: { type: "object", properties: {} },
        },
      ],
    };
  });

  // Handler: ejecutar herramienta
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    switch (request.params.name) {
      case "ping":
        const message = request.params.arguments?.message || "pong";
        return {
          content: [{ type: "text", text: `🏓 ${message}` }],
        };

      case "get_user_info":
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  userId,
                  serverVersion: "0.1.0",
                  timestamp: new Date().toISOString(),
                },
                null,
                2
              ),
            },
          ],
        };

      default:
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${request.params.name}`
        );
    }
  });

  return server;
}

// Endpoint principal MCP (GET, POST, DELETE)
mcpHttpRouter.all("/", authenticateMcpRequest, async (req, res) => {
  const userId = res.locals.auth.subject;
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  let transport: StreamableHTTPServerTransport;

  // Verificar si es una sesión existente
  if (sessionId && transports.has(sessionId)) {
    logger.info({ userId, sessionId }, "Reusing existing MCP session");
    transport = transports.get(sessionId)!;
  }
  // O si es una nueva sesión (mensaje "initialize")
  else if (req.method === "POST" && isInitializeRequest(req.body)) {
    logger.info({ userId }, "Creating new MCP session");

    // Crear servidor MCP para este usuario
    const mcpServer = createMcpServer(userId);

    // Crear transporte HTTP
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (newSessionId) => {
        logger.info(
          { userId, sessionId: newSessionId },
          "MCP session initialized"
        );
        transports.set(newSessionId, transport);
      },
    });

    // Conectar servidor al transporte
    await mcpServer.connect(transport);
  } else {
    return res.status(400).json({ error: "Invalid session or request" });
  }

  // Delegar el request al transport (él maneja JSON-RPC)
  await transport.handleRequest(req, res, req.body);
});

Conceptos clave:

  1. Transport: Abstracción del SDK de MCP que maneja la serialización JSON-RPC
  2. Server: Contiene la lógica de las herramientas
  3. Session: Cada conexión MCP tiene un UUID único (Mcp-Session-Id)
  4. Handler: Funciones que responden a métodos específicos (tools/list, tools/call)

3. /src/routes/well-known/index.ts

Responsabilidad: Discovery endpoints para OAuth y MCP

GET /.well-known/anthropic/manifest.json

wellKnownRouter.get("/anthropic/manifest.json", (req, res) => {
  // Si viene con Authorization + Accept: text/event-stream
  // → Es Claude Desktop intentando conectar, redirigir al MCP
  if (
    req.headers.authorization &&
    req.headers.accept?.includes("text/event-stream")
  ) {
    return res.redirect(307, "/mcp");
  }

  // Si no, es discovery normal → devolver manifest
  res.json({
    schema_version: "1.0",
    name_for_human: "Leonobitech Claude Desktop Connector",
    name_for_model: "leonobitech_claude",
    description_for_human: "Autentica y habilita la integración MCP...",
    description_for_model: "Utiliza herramientas de Leonobitech...",
    contact_email: "security@leonobitech.com",
    legal_info_url: "https://www.leonobitech.com/legal",
    logo_url: "https://www.leonobitech.com/logo.png",

    oauth: {
      client_id: env.CLIENT_ID,
      scopes: ["claude.app", "claudeai"],
      scope: "claude.app claudeai",
      authorization_url: `${env.PUBLIC_URL}/oauth/authorize`,
      token_url: `${env.PUBLIC_URL}/oauth/token`,
      redirect_uri: env.REDIRECT_URI,
    },

    api: {
      type: "http",
      url: `${env.PUBLIC_URL}/mcp`,
      is_user_authenticated: true,
    },
  });
});

// También responder a POST (Claude Desktop lo usa)
wellKnownRouter.post("/anthropic/manifest.json", (req, res) => {
  if (req.headers.authorization) {
    // Es una request MCP disfrazada → redirigir
    return res.redirect(307, "/mcp");
  }
  res.status(405).json({ error: "Method not allowed" });
});

¿Por qué la redirección? Claude Desktop hace POST al manifest cuando quiere enviar mensajes MCP. Tu servidor redirige al endpoint correcto.

GET /.well-known/oauth-authorization-server

wellKnownRouter.get("/oauth-authorization-server", (req, res) => {
  res.json({
    issuer: env.PUBLIC_URL,
    authorization_endpoint: `${env.PUBLIC_URL}/oauth/authorize`,
    token_endpoint: `${env.PUBLIC_URL}/oauth/token`,
    registration_endpoint: `${env.PUBLIC_URL}/oauth/register`,
    jwks_uri: `${env.PUBLIC_URL}/.well-known/jwks.json`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256", "plain"],
    scopes_supported: ["claude.app", "claudeai"],
    token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
  });
});

RFC 8414: OAuth 2.0 Authorization Server Metadata

GET /.well-known/oauth-protected-resource

wellKnownRouter.get("/oauth-protected-resource", (req, res) => {
  res.json({
    resource: `${env.PUBLIC_URL}/mcp`, // ¡Clave! Indica dónde está el recurso
    issuer: env.PUBLIC_URL,
    authorization_servers: [env.PUBLIC_URL],
    bearer_methods_supported: ["header"],
    resource_scopes_supported: ["claude.app", "claudeai"],
    resource_documentation: `${env.PUBLIC_URL}/.well-known/anthropic/manifest.json`,
  });
});

RFC 9728: OAuth 2.0 Protected Resource Metadata

¿Por qué es importante? Claude Desktop usa este endpoint para descubrir DÓNDE está el MCP después de hacer OAuth. Sin esto, no sabría que debe conectarse a /mcp.

GET /.well-known/jwks.json

wellKnownRouter.get("/jwks.json", async (req, res) => {
  // Leer el archivo keys/jwks.json
  const jwks = JSON.parse(await readFile("keys/jwks.json", "utf-8"));
  res.json(jwks);
});

Contenido de jwks.json:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "claude-key-1",
      "use": "sig",
      "alg": "RS256",
      "n": "xGOz8...", // Módulo de la clave pública (base64url)
      "e": "AQAB" // Exponente (típicamente 65537)
    }
  ]
}

¿Para qué? Permite a clientes verificar la firma de los JWTs sin contactar al servidor. Es parte del estándar OpenID Connect.


📜 Protocolos y Estándares

Tu servidor implementa estos RFCs oficiales:

RFC 6749: OAuth 2.0 Framework

  • Base de OAuth 2.0
  • Authorization Code Grant

RFC 7636: PKCE

  • Extensión de seguridad para OAuth
  • Obligatoria en OAuth 2.1

RFC 7591: Dynamic Client Registration

  • /oauth/register endpoint
  • Auto-registro de clientes

RFC 8414: OAuth 2.0 Authorization Server Metadata

  • /.well-known/oauth-authorization-server
  • Discovery automático de endpoints

RFC 9728: OAuth 2.0 Protected Resource Metadata

  • /.well-known/oauth-protected-resource
  • Indica dónde está el recurso protegido (tu MCP)

RFC 7519: JWT

  • JSON Web Token format
  • RS256 signature algorithm

MCP Specification 2025-06-18

  • Anthropic's Model Context Protocol
  • JSON-RPC 2.0 transport over HTTP
  • Streamable HTTP transport (aunque el nombre es confuso, es HTTP normal)

🔒 Seguridad

Capas de Seguridad

  1. PKCE: Previene code interception attack
  2. State parameter: Anti-CSRF en OAuth flow
  3. JWT signature (RS256): Previene token forgery
  4. Token en Redis: Permite revocación instantánea
  5. Short-lived tokens: access_token expira en 5min
  6. Refresh rotation: refresh_token de un solo uso
  7. HTTPS: Todo el tráfico cifrado (via Traefik)
  8. Scope validation: Solo concede permisos solicitados

Vectores de Ataque (Mitigados)

1. Authorization Code Interception

Ataque: Alguien intercepta el code en la redirección.

Mitigación: PKCE. Sin el code_verifier original, el code es inútil.

2. Token Replay Attack

Ataque: Alguien captura un access_token y lo reutiliza.

Mitigación:

  • Token expira en 5min
  • Puedes revocar en Redis (DEL token:{jti})
  • HTTPS previene captura en tránsito

3. Token Forgery

Ataque: Alguien intenta crear un JWT falso.

Mitigación: Firma RSA. Sin la clave privada (que está en tu servidor), es imposible crear una firma válida.

4. CSRF en OAuth

Ataque: Atacante engaña al usuario para autorizar su propio client.

Mitigación: state parameter. El cliente genera un random y verifica que vuelve igual.

5. Redirect URI Manipulation

Ataque: Atacante cambia redirect_uri para recibir el code.

Mitigación: Whitelist de URIs permitidas. Solo https://claude.ai/api/mcp/auth_callback es aceptado.

Mejoras de Seguridad Recomendadas

Para producción, considera:

// 1. Rate limiting
import rateLimit from "express-rate-limit";

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  max: 5, // 5 intentos
  message: "Too many authorization attempts",
});

app.use("/oauth/authorize", authLimiter);

// 2. Refresh token rotation
// Cada vez que usas un refresh_token:
// - Generas uno nuevo
// - Invalidas el anterior
// - Guardas "familia" de tokens para detectar reuso

// 3. Login real con Prisma
// En lugar de auto-aprobar, mostrar UI:
app.get("/oauth/authorize", async (req, res) => {
  if (!req.session?.userId) {
    return res.redirect(
      `/login?return_to=${encodeURIComponent(req.originalUrl)}`
    );
  }

  // Mostrar consent screen
  res.render("consent", { client, scopes });
});

// 4. Auditoría
// Loggear todos los eventos de seguridad
loggerSecurityEvent("token_issued", { userId, clientId, scope });
loggerSecurityEvent("token_revoked", { userId, jti, reason });

// 5. Revocación
app.post("/oauth/revoke", async (req, res) => {
  const { token } = req.body;
  const { jti } = jwt.decode(token);
  await redis.del(`token:${jti}`);
  res.status(200).json({ revoked: true });
});

🐛 Troubleshooting

Problema: "Invalid token" después de OAuth

Síntomas:

POST /mcp
401 Unauthorized
{"error": "invalid_token"}

Causas posibles:

  1. Token expiró (5min de vida)
   # Verificar en Redis
   redis-cli -h redis_core -a PASSWORD
   > SELECT 5
   > TTL token:abc123
   # Si retorna -2 → expiró
  1. Token no está en Redis
   > EXISTS token:abc123
   # Si retorna 0 → nunca se guardó o ya se borró
  1. Firma inválida
   # Verificar que publicKey.pem coincide con la clave usada para firmar
   # Verificar jwks.json tiene el `kid` correcto

Solución:

  • Usa refresh_token para obtener nuevo access_token
  • Verifica que Redis DB=5 es la correcta
  • Revisa logs del servidor para ver errores de verificación

Problema: Claude Desktop no se conecta después de OAuth

Síntomas:

  • OAuth completa exitosamente
  • Pero no aparecen herramientas en Claude Desktop
  • Logs muestran que no llegan requests POST al /mcp

Causas:

  1. Manifest sin resource en oauth-protected-resource
   // ✗ Incorrecto
   {
     "issuer": "...",
     "resource_scopes_supported": ["claude.app"]
   }

   // ✓ Correcto
   {
     "resource": "https://connector.leonobitech.com/mcp",  // ← NECESARIO
     "issuer": "...",
     "authorization_servers": ["..."],
     "resource_scopes_supported": ["claude.app"]
   }
  1. Manifest con type: "sse" en lugar de "http"
   // ✗ Incorrecto (Claude Desktop ya no usa SSE)
   {
     "api": {
       "type": "sse",
       "url": "https://.../mcp/sse"
     }
   }

   // ✓ Correcto (usa HTTP JSON-RPC)
   {
     "api": {
       "type": "http",
       "url": "https://.../mcp"
     }
   }
  1. Scope mismatch

- Claude pide claudeai - Servidor solo acepta claude.app - Solución: SCOPES=claude.app claudeai

Problema: "Method not found" en herramienta

Síntomas:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found"
  }
}

Causa: El método MCP no tiene handler registrado.

Ejemplo:

// Claude Desktop llama:
// {"method": "prompts/list", ...}

// Pero tu servidor solo tiene:
server.setRequestHandler(ListToolsRequestSchema, ...);
server.setRequestHandler(CallToolRequestSchema, ...);
// No tiene handler para "prompts/list"

Solución:

// Agregar handler para prompts
server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return { prompts: [] }; // Sin prompts por ahora
});

// O si no soportas prompts, declararlo en capabilities:
const server = new Server(
  { name: "...", version: "..." },
  {
    capabilities: {
      tools: {}, // Sí soportamos tools
      // prompts: {} // No soportamos prompts (comentado)
    },
  }
);

Problema: CORS errors en browser

Síntomas:

Access to XMLHttpRequest at 'https://connector.leonobitech.com/oauth/authorize'
from origin 'https://claude.ai' has been blocked by CORS policy

Solución:

// src/index.ts
app.use(
  cors({
    origin: [
      "https://claude.ai",
      "https://app.claude.ai",
      "https://desktop.claude.ai",
      env.PUBLIC_URL,
    ],
    credentials: true,
  })
);

Debugging Tips

# 1. Ver logs en tiempo real
docker logs -f claude_oauth | jq .

# 2. Inspeccionar JWT
# Copiar el token de los logs
echo "eyJhbGci..." | cut -d. -f2 | base64 -d | jq .

# 3. Ver tokens en Redis
redis-cli -h redis_core -a PASSWORD
SELECT 5
KEYS token:*
GET token:abc123

# 4. Ver authorization codes
KEYS authcode:*
TTL authcode:xyz789

# 5. Verificar firma JWT manualmente
# Descargar jwks.json
curl https://connector.leonobitech.com/.well-known/jwks.json

# Usar herramienta como https://jwt.io
# Pegar token + clave pública

# 6. Testear OAuth flow manualmente
# a) Registro
curl -X POST https://connector.leonobitech.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
    "grant_types": ["authorization_code", "refresh_token"]
  }'

# b) Autorización (en browser)
# https://connector.leonobitech.com/oauth/authorize?response_type=code&client_id=claude-mcp&redirect_uri=https://claude.ai/api/mcp/auth_callback&scope=claude.app&code_challenge=HASH&code_challenge_method=S256&state=RANDOM

# c) Token
curl -X POST https://connector.leonobitech.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=ABC&code_verifier=VERIFIER&client_id=claude-mcp&client_secret=SECRET&redirect_uri=https://claude.ai/api/mcp/auth_callback"

# d) Usar MCP
curl -X POST https://connector.leonobitech.com/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "test", "version": "1.0"}
    },
    "id": 0
  }'

🎓 Conceptos Avanzados

¿Por qué HTTP y no WebSocket?

MCP soporta varios transportes:

  • stdio: Proceso local (stdin/stdout)
  • SSE: Server-Sent Events (deprecated)
  • HTTP (Streamable): HTTP normal con JSON-RPC

Claude Desktop usa HTTP (mal llamado "Streamable HTTP"):

  • Cada request/response es independiente (stateless)
  • La "sesión" se mantiene con Mcp-Session-Id header
  • Más simple que WebSocket
  • Compatible con load balancers, CDNs, etc.

¿Qué es "Streamable" entonces?

El nombre es confuso. "Streamable HTTP" significa:

  • El servidor puede enviar respuestas stream (chunked transfer)
  • Pero para herramientas simples, responde completo (no stream)

Ejemplo de stream:

// Respuesta normal
res.json({ result: "..." });

// Respuesta stream (para herramientas que tardan)
res.setHeader("Transfer-Encoding", "chunked");
res.write('{"jsonrpc":"2.0","result":');
await processLongTask((chunk) => {
  res.write(JSON.stringify(chunk));
});
res.write("}");
res.end();

Gestión de Sesiones MCP

Cada conexión MCP tiene:

{
  sessionId: "28c85319-d08a-4063-b30c-34f24b92c20c",
  userId: "claude-user",
  transport: StreamableHTTPServerTransport,
  server: Server,
  createdAt: Date,
  lastActivity: Date
}

Lifecycle:

  1. Claude Desktop envía initialize → Creas sesión
  2. Claude Desktop envía tools/list con Mcp-Session-Id → Reutilizas sesión
  3. ... más requests con mismo Mcp-Session-Id ...
  4. (No hay mensaje de "close" explícito)

Garbage Collection:

// Limpieza de sesiones inactivas (agregar esto)
setInterval(() => {
  const now = Date.now();
  for (const [sessionId, info] of sessions.entries()) {
    if (now - info.lastActivity > 30 * 60 * 1000) {
      // 30min
      info.server.close();
      sessions.delete(sessionId);
      logger.info({ sessionId }, "Session cleaned up (inactive)");
    }
  }
}, 5 * 60 * 1000); // Cada 5min

Múltiples Usuarios

Actualmente hardcodeado a userId="claude-user". Para multi-user:

// 1. En /oauth/authorize, mostrar login real
app.get("/oauth/authorize", (req, res) => {
  if (!req.session?.user) {
    // Redirigir a login
    return res.redirect(`/login?return=${encodeURIComponent(req.originalUrl)}`);
  }

  const userId = req.session.user.id;
  // ... generar code con este userId real
});

// 2. En createMcpServer, usar userId para scope
function createMcpServer(userId: string) {
  // Cada usuario puede tener herramientas diferentes
  const user = await prisma.user.findUnique({ where: { id: userId } });

  const tools = [];
  if (user.hasDataAccess) {
    tools.push({
      name: "query_database",
      description: "Query user's database",
    });
  }

  server.setRequestHandler(ListToolsRequestSchema, async () => {
    return { tools };
  });
}

📚 Recursos para Aprender Más

OAuth 2.1

JWT

MCP

Testing

# MCP Inspector (herramienta oficial)
npx @modelcontextprotocol/inspector

# OAuth Playground
# https://www.oauth.com/playground/

✅ Checklist de Comprensión

Después de leer este documento, deberías poder responder:

  • [ ] ¿Qué es PKCE y por qué es importante?
  • [ ] ¿Cuál es la diferencia entre authorization code y access token?
  • [ ] ¿Por qué usamos RS256 en lugar de HS256 para JWT?
  • [ ] ¿Qué pasa si alguien roba un access_token?
  • [ ] ¿Cómo descubre Claude Desktop dónde está el endpoint MCP?
  • [ ] ¿Qué es el Mcp-Session-Id header?
  • [ ] ¿Por qué guardamos tokens en Redis además de firmarlos con JWT?
  • [ ] ¿Cuál es el propósito de /.well-known/oauth-protected-resource?
  • [ ] ¿Qué pasaría si cambias la clave RSA privada?
  • [ ] ¿Cómo agregarías una nueva herramienta MCP?

🚀 Próximos Pasos

Herramientas que Podrías Agregar

// 1. Consulta a base de datos
{
  name: "query_users",
  description: "Query users from database",
  inputSchema: {
    type: "object",
    properties: {
      limit: { type: "number", maximum: 100 }
    }
  }
}

// Handler
case "query_users":
  const users = await prisma.user.findMany({
    take: arguments.limit || 10
  });
  return {
    content: [{
      type: "text",
      text: JSON.stringify(users, null, 2)
    }]
  };

// 2. Envío de emails
{
  name: "send_email",
  description: "Send email via Resend",
  inputSchema: {
    type: "object",
    properties: {
      to: { type: "string", format: "email" },
      subject: { type: "string" },
      body: { type: "string" }
    },
    required: ["to", "subject", "body"]
  }
}

// 3. Llamar a API externa
{
  name: "get_weather",
  description: "Get current weather for a city",
  inputSchema: {
    type: "object",
    properties: {
      city: { type: "string" }
    }
  }
}

Mejoras de Producción

  1. Login UI real

- Formulario de login - Consent screen para OAuth - Gestión de usuarios con Prisma

  1. Multi-tenancy

- Diferentes client_id por cliente - Scopes granulares - Rate limiting por cliente

  1. Monitoreo

- Prometheus metrics - Alertas en Sentry - Dashboard con Grafana

  1. Testing

- Unit tests (Jest) - Integration tests (Supertest) - E2E tests (Playwright)


📜 License

MIT © 2025 — Leonobitech


🥷 Leonobitech Dev Team

www.leonobitech.com

Made with 🧠 and AI love 🤖

目录标签

目录标签

远程服务TypeScriptClaudeOAuth认证本地部署JSON-RPC安全连接自定义工具

支持客户端

Claude DesktopClaude

接入字段

传输方式(transport,传输协议)

stdio

鉴权方式(authType,认证方式)

oauth

运行时(runtime,运行环境)

Node.js

来源包(packageName,安装包名)

@modelcontextprotocol/inspector

工具数量(toolCount,工具数)

2

资源数量(resourceCount,资源数)

0

提示词数量(promptCount,提示词数)

0

权限和风险

stdiooauth部署方式未说明

接入前请确认传输方式、认证方式和部署位置,并根据实际工具能力限制访问范围。

安装前确认

不要直接授予不必要的文件、网络或账号权限;先核对安装命令和配置内容。

来源信息

继续浏览同类 MCP