Gabriel Herencia

~/insights / Cloud & DevOps

OAuth 2.1 para un MCP remoto: tres bugs y un bloqueo que no salía en los logs

Migré la autenticación de mi servidor MCP de un token pegado a mano a OAuth 2.1 completo. Lo difícil no fue el estándar: fue un 401 que mentía, un puerto efímero y un bloqueo en el borde que la aplicación nunca llegó a ver.

·9 min de lectura
Cloud & DevOpsClaude CodeDepuraciónSeguridadMCPWallidaFastifycloudflareoauth
Portada: OAuth 2.1 para un MCP remoto: tres bugs y un bloqueo que no salía en los logs

El punto de partida: un secreto que viajaba a mano

El servidor MCP de Wallida funcionaba, pero su única credencial era un token estático que el usuario copiaba y pegaba en la configuración de su cliente. O, peor, dentro de la propia URL.

Eso tiene dos problemas que ninguna mejora del token resuelve. El primero es obvio: el secreto acaba en un fichero de configuración, en el historial del shell y —en la variante de URL— en los logs de acceso de cualquier proxy por el que pase. Cuando lo que hay al otro lado es historia clínica de menores, eso no es un detalle.

El segundo es más terco: los clientes reales no lo aceptan. La especificación de MCP define el servidor como un resource server de OAuth 2.1. Sin eso, el conector se quedaba permanentemente en «installed but requires authentication». No le faltaba permiso: le faltaba el mapa.

Las piezas que no son opcionales

Montar el servidor de autorización dentro del mismo backend (Fastify) resultó menos trabajo del esperado. Lo que sí sorprende es cuántas piezas son obligatorias porque su ausencia rompe un cliente concreto, no por purismo:

  • RFC 9728 — metadata del recurso protegido. Y en dos rutas: la especificación inserta el path del recurso después del segmento well-known (/.well-known/oauth-protected-resource/api/v1/mcp), lo cual es contraintuitivo y se equivoca todo el mundo la primera vez. Hay clientes que solo prueban esa forma y clientes que solo prueban la raíz.
  • WWW-Authenticate con resource_metadata en cada 401. Es el arranque de todo: el cliente lee esa cabecera, descubre el servidor de autorización y se registra solo.
  • RFC 8414 — metadata del servidor de autorización.
  • RFC 7591 — registro dinámico. Sin registration_endpoint, un cliente nativo aborta: elige un puerto de callback al vuelo y no hay nada que pre-registrar.
  • PKCE S256, anunciado en code_challenge_methods_supported. Un cliente que exige PKCE y no ve el anuncio da por hecho que no lo soportas.
  • RFC 8707 — resource. El token se emite para un recurso y el servidor valida esa audiencia. Es lo que impide el token passthrough.

Y una que se olvida siempre: los .well-known y los endpoints del protocolo tienen que salir con CORS abierto y sin credenciales. Los lee cualquier cliente desde cualquier origen y no llevan cookies —la credencial va en Authorization, que el cliente pone explícitamente—, así que restringirlos no protege nada y sí impide conectar.

Tres decisiones de diseño que me ahorraron problemas

Los scopes son una proyección, no un modelo nuevo

Ya tenía un sistema de permisos que funcionaba: el filtro de herramientas es la intersección de rol del usuario ∩ alcance lectura/escritura ∩ grupos de funcionalidad. La tentación era inventar un eje de scopes paralelo.

No lo hice. wallida:<grupo> mapea 1:1 a los grupos que ya existían y wallida:write es el eje de escritura, así que la traducción devuelve exactamente el mismo par que producía un token estático y el filtro de herramientas no cambió ni una línea.

Dos ejes de permisos que hay que mantener sincronizados acaban desincronizados. Y el día que pasa, gana el más permisivo.

Tokens opacos, no JWT

Un JWT autocontenido se valida sin tocar la base y sobrevive a su propia revocación hasta que expira. Es un compromiso legítimo si te ahorra un viaje. Aquí no me ahorraba nada: el servidor ya consulta la base en cada petición para resolver el perfil.

Con un token opaco hasheado en base, «revocar» significa revocado ahora.

Rotación con detección de reuso

Los refresh tokens rotan en cada uso. Pero rotar por sí solo no basta: si alguien roba uno y lo canjea antes que el dueño legítimo, se queda con la cadena y el dueño recibe un error suelto que parece un fallo de red.

Cada token lleva un identificador de familia. Un refresh ya rotado que reaparece se trata como robo y revoca la familia entera, access tokens incluidos. Lo mismo con un código canjeado dos veces. Prefiero la molestia de volver a autorizar al riesgo de una copia viva.

El fallo abierto que casi se me cuela

Aguas abajo, una lista de capacidades vacía significaba «todos los grupos» —era el valor de los tokens emitidos antes de que existieran los permisos por área, y no podía cambiarlo sin dejar a ciegas integraciones vivas.

Un consentimiento en el que el usuario no marca nada habría producido, por ese camino, un token que lo abre todo. Se rechaza en el endpoint de consentimiento y se vuelve a comprobar al resolver el token. Dos veces, a propósito.

Los tres bugs

1. Un 401 que mentía sobre la credencial

El hook de autenticación corría antes del enrutado, así que también se ejecutaba sobre rutas que no existían. El endpoint MCP solo tenía registrado el POST; un GET —que es como el transporte abre su stream de eventos— caía en el 404 del framework y salía convertido en 401.

Efecto: un cliente que acababa de autenticarse correctamente abría su stream, recibía un 401, y concluía lo único que un 401 puede significar. Los mensajes de error apuntaban al token y a la URL, que eran justo lo único que estaba bien.

La respuesta correcta para un servidor sin estado es 405 con Allow: POST: no hay stream que ofrecer ni sesión que cerrar. Y va sin autenticación a propósito — exigir credencial para decir «este verbo no existe aquí» reintroduce el mismo 401 engañoso.

2. El puerto efímero

Un cliente nativo no puede reservar un puerto fijo en la máquina del usuario: puede estar ocupado. Así que declara http://localhost/callback y redirige al puerto que consiga esa sesión.

Yo comparaba la redirect_uri byte a byte. Rechazaba siempre. RFC 8252 §7.3 obliga a ignorar el puerto en URIs de loopback justamente por esto.

La excepción tiene que ser estrecha: solo http:// de loopback (que por definición no sale de la máquina), sin cruzar localhost con 127.0.0.1, y exigiendo que ruta y query coincidan. En https:// la comparación sigue siendo exacta con puerto — relajarla ahí sería una redirección abierta hacia otro servicio del mismo host.

3. El CORS demasiado ancho y demasiado estrecho a la vez

Abrí el régimen permisivo a todo el prefijo del OAuth. Pero ese régimen solo permitía GET/POST/OPTIONS, y el botón de «retirar acceso» hace DELETE. Fallaba solo en el navegador: ni en los tests, ni en curl.

La corrección fue separar lo que son endpoints del protocolo (registro, token, revocación → abiertos a cualquier origen) de los endpoints de la aplicación (consentimiento, gestión de accesos → acotados, con credenciales).

El bloqueo invisible

Con todo eso arreglado, el flujo se completaba entero. Los logs mostraban esto:

POST 401  /api/v1/mcp                  (sonda; dispara el descubrimiento)
GET  200  /.well-known/oauth-protected-resource/...
POST 201  /oauth/register
GET  302  /oauth/authorize
POST 200  /oauth/authorize             (consentimiento del usuario)
POST 200  /oauth/token                 (access + refresh emitidos)
── y nada más ──

El cliente recibía el token y no volvía a llamar jamás. Ni una petición más, a ninguna ruta.

Verifiqué el token fila a fila en la base: vigente, sin revocar, audiencia correcta, tenant coincidente, perfil activo. Si lo hubiera presentado, habría entrado. Ejecuté el checklist de diagnóstico del proveedor: DNS público enrutable, sin redirecciones, descubrimiento a 200, PKCE anunciado, endpoint de token respondiendo en 650 ms sobre un límite de 10 segundos. Todo verde.

Y entonces conecté el mismo servidor desde la CLI: funcionó a la primera, dieciséis llamadas de herramientas con datos reales.

Ese contraste era la pista, y estaba en la documentación del proveedor todo el tiempo: «funciona en la CLI o en curl pero no en la versión hospedada» es la firma de un bloqueo en el borde. La CLI conecta desde tu máquina; la versión hospedada, desde el rango de salida del proveedor.

El panel del CDN lo confirmó en treinta segundos. Cinco eventos Block, servicio Managed rules, y la regla concreta: «Manage AI bots», disparada por el User-Agent del agente. Las horas cuadraban al segundo con el momento en que se emitía el token.

El bloqueo ocurría antes de llegar a la aplicación. Por eso los logs del servidor mostraban silencio en lugar de un error: las peticiones nunca llegaban.

Lo que más despistaba era una asimetría: el descubrimiento, el registro y el canje del token los hace la infraestructura del proveedor con un User-Agent de librería HTTP, que no estaba bloqueado. Solo la llamada al MCP usa el User-Agent del agente. Mismo cliente, mismo rango de IPs, unas peticiones pasaban y otras no.

La solución fue una regla de excepción acotada al endpoint del MCP, saltando las reglas gestionadas solo ahí. Es un intercambio asumible: ese endpoint no es la superficie que un WAF genérico protege —no hay HTML, ni formularios, ni SQL construido con strings—, sino JSON-RPC validado por esquema, detrás de un token opaco verificado en cada petición y con todo lo que sirve pasando por Row Level Security. La protección real vive dentro, como conté en construyendo-wallida-saas-multitenant.

Lo que me llevo

Un 401 en el lugar equivocado miente sobre la credencial. Es el error más caro de depurar, porque manda a todo el mundo a revisar el token — que es lo único que estaba bien. Si añades rutas donde un cliente pueda sondear verbos, comprueba qué devuelve el método no soportado.

Los logs de tu aplicación no ven lo que el borde bloquea. Pasé horas leyendo silencio y concluyendo que el cliente no lo intentaba. Sí lo intentaba: cuatro veces en diez segundos. Cuando el rastro se corta justo después de una respuesta correcta, mira el CDN antes que el código.

Verifica contra datos, no contra el diagnóstico heredado. Arranqué con tres hallazgos de una sesión anterior. Dos eran falsos: un supuesto bug de serialización JSON que no existía —los saltos de línea sí iban escapados, verificado volcando los bytes— y una supuesta escalada de privilegios que en realidad era el control de acceso funcionando. Solo el tercero era real. Es la misma idea de siempre: verdad-de-fondo-depurar-con-evidencia.

La documentación del proveedor pide cosas que el estándar no pide. Timeouts concretos, tratamiento del puerto en loopback, qué scopes se solicitan por defecto, qué pasa si tu URL redirige a otro host. Leerla antes me habría ahorrado dos de los tres bugs.

Y la de siempre: barandas, no prohibiciones. El consentimiento no marca nada por defecto salvo lo mínimo, la escritura va desmarcada, y el acceso se retira desde la interfaz con corte inmediato. Es la misma mentalidad de como-construi-mi-propio-mcp-de-postgresql y de automatizar-las-barreras-mcp-hooks-skills: que la regla viva en la infraestructura y no en mi memoria.

El resultado, al final, es que conectar un agente ya no exige copiar ningún secreto: pegas una dirección, inicias sesión, marcas a qué le das acceso y listo. Que es como debería haber sido desde el principio.