BlogIngeniería

OAuth en un servidor MCP remoto: lo que aprendimos al lanzarlo

Notas de un servidor MCP en producción que usa Supabase Auth como servidor de autorización: lo que pide la especificación, en qué nos equivocamos y lo que aún no hemos probado.

Escrito por
Publicado
Actualizado
Extensión
12 min de lectura

La respuesta corta

Un servidor MCP remoto es un recurso protegido por OAuth. Sin token, responde 401 y señala sus metadatos RFC 9728; el cliente los sigue hasta el servidor de autorización, se registra, hace que la persona inicie sesión con PKCE y el parámetro resource, y vuelve con un token bearer emitido para la URL del servidor. Lo ejecutamos en producción con Supabase Auth. Tres detalles dieron trabajo: un 401 con indicación de scope y sin código de error, un hook de tokens que añade nuestra audiencia y nuestro scope a los tokens de Supabase, y el registro dinámico de clientes, ya que nuestro servidor de autorización no ofrece documentos de metadatos de ID de cliente.

¿Quién hace qué cuando un servidor MCP usa OAuth?

El servidor MCP tiene la tarea más pequeña: acepta o rechaza tokens bearer. La especificación de autorización de MCP (versión 2025-11-25) lo convierte en un servidor de recursos de OAuth 2.1 y al asistente en un cliente de OAuth, y deja que el servidor de autorización, que hace iniciar sesión a la persona y emite los tokens, sea un servicio aparte.

El nuestro es aparte. El servidor OAuth 2.1 de Supabase Auth emite todos los tokens, nuestro sitio web aporta la página de inicio de sesión y de consentimiento, y el endpoint en https://mcp.scorestarling.com/mcp solo verifica tokens. Funciona con Streamable HTTP sin sesiones MCP y comprueba el token en cada solicitud. ScoreStarling convierte grabaciones en partituras editables, y este endpoint es la vía por la que los asistentes llegan a ella; la guía de conexión muestra los pasos que sigue la gente.

¿Qué pasa cuando se conecta un cliente MCP?

El cliente parte solo de la URL. Dos respuestas de nuestro servidor, tal como eran el 3 de octubre de 2026, le dicen dónde iniciar sesión; seis pasos más le consiguen un token. Primero, una solicitud sin token recibe un 401 cuyo encabezado WWW-Authenticate nombra los metadatos y los scopes que pedir:

$ curl -si -X POST https://mcp.scorestarling.com/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"

{"error_description": "Authentication required"}

Segundo, el cliente obtiene esos metadatos. RFC 9728 forma su dirección insertando /.well-known/oauth-protected-resource entre el host y la ruta, y exige que resource sea idéntico a la URL que usa el cliente; si no, el cliente debe detenerse. Aquí está sustituida la referencia de nuestro proyecto de Supabase:

{
  "resource": "https://mcp.scorestarling.com/mcp",
  "authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
  "scopes_supported": ["openid", "email", "profile"],
  "bearer_methods_supported": ["header"]
}

Los otros seis pasos:

  1. El cliente lee los metadatos del servidor de autorización. El emisor de Supabase tiene una ruta, así que RFC 8414 pone el segmento well-known delante de ella: https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. El cliente comprueba que issuer coincida exactamente y que code_challenge_methods_supported incluya S256; la especificación MCP dice que se detenga si falta ese campo.

  2. Envía sus propios metadatos al registration_endpoint anunciado (registro dinámico de clientes, RFC 7591) y recibe un client_id. Nuestro cliente de prueba se registra como cliente público sin secreto; la documentación de Anthropic dice que Claude también.

  3. Abre el navegador en el endpoint de autorización con un desafío PKCE S256, el scope del 401 y resource=https://mcp.scorestarling.com/mcp (RFC 8707). Supabase pasa el navegador a nuestra página de consentimiento, que hace iniciar sesión a la persona si hace falta y le pide aprobar o denegar el cliente.

  4. Intercambia el código con su verificador PKCE y el mismo resource, y recibe un token de acceso que dura una hora más un token de renovación.

  5. Envía Authorization: Bearer … en cada solicitud MCP. Comprobamos la firma con las claves publicadas del emisor, el emisor, la audiencia, la caducidad y el scope de escritura, y después que la cuenta no se haya desactivado.

  6. Cuando el token caduca, o respondemos 401 con invalid_token, lo renueva, otra vez con resource, y recibe un token de acceso y un token de renovación nuevos.

Los clientes deben enviar resource en las solicitudes de autorización y de token aunque el servidor de autorización lo ignore. La documentación del flujo OAuth de Supabase no menciona el parámetro (comprobado el 3 de octubre de 2026). Supabase lo aceptó en todas nuestras ejecuciones reales, y la audiencia de nuestros tokens viene de un hook.

¿Qué debe contener el encabezado WWW-Authenticate del 401?

Cuando no se envió ningún token: dónde están los metadatos, qué scopes pedir y ningún código de error. RFC 6750 §3.1 dice que una solicitud sin ninguna información de autenticación no debería recibir un código de error. invalid_token es para un token que se envió y falló, y le dice al cliente que puede conseguir uno nuevo y reintentar. La especificación MCP añade que el 401 debe llevar una indicación de scope, que los clientes deben tratar como los scopes que pedir.

Descubrimos que nuestro propio servidor lo hacía mal mientras escribíamos nuestra guía de conexión. En MCP Python SDK 1.30.0, RequireAuthMiddleware responde a toda solicitud sin autenticar con error="invalid_token" y sin scope, de modo que a un cliente que nunca había enviado un token se le decía que su token no era válido. Ahora un pequeño middleware ASGI alrededor de la ruta MCP reescribe solo los 401 del SDK; el SDK sigue decidiendo quién entra, y su respuesta 403 insufficient_scope queda intacta:

# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Authentication required",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp"

# No token, now
WWW-Authenticate: Bearer
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Invalid or expired access token",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

La indicación nombra openid email profile, los scopes que Supabase puede conceder, que coinciden con scopes_supported. Nuestra comprobación de despliegue ahora falla si un desafío sin token lleva un código de error. La corrección llegó a producción el 2 de octubre de 2026, a los cuatro minutos de integrarla. La documentación de conectores de Anthropic añade dos reglas para Claude: el inicio de sesión solo arranca desde un 401 (ignora WWW-Authenticate en una respuesta 200), y solo usa la primera entrada de authorization_servers.

¿Cómo se consigue una audiencia y un scope de MCP en los tokens de Supabase?

Con un hook de token de acceso personalizado. La especificación MCP dice que un servidor debe aceptar solo tokens emitidos para él, lo que en la práctica significa comprobar aud, y queríamos un scope de escritura propio; la documentación de Supabase dice que su servidor OAuth no admite scopes personalizados (comprobado el 3 de octubre de 2026). El hook es una función de Postgres que Supabase Auth ejecuta antes de emitir un token y que puede reescribir claims. El nuestro, algo simplificado respecto a nuestra migración:

create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
  -- Tokens issued to an OAuth client carry its client_id; website sessions don't.
  if coalesce(claims->>'client_id', '') <> '' then
    claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
    claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
  end if;
  return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;

Por el lado de MCP, esos claims se comprueban con PyJWT, y después se aplican dos reglas propias:

claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
                    issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
                    options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
    return None  # the client gets 401 invalid_token
# then: has this account been turned off?

ACCEPTED_AUDIENCES es la URL de MCP, más la antigua durante un cambio de dirección. La comprobación del ID de cliente deja fuera el inicio de sesión del propio sitio web: un token de sesión del navegador del mismo proyecto de Supabase tiene el mismo emisor y las mismas claves, pero no client_id, así que nunca recibe la audiencia de MCP.

El hook tiene dos costes. Asigna una audiencia fija en lugar de seguir el resource del cliente, algo que funciona mientras un servidor de autorización sirve a un solo servidor MCP. Y nuestra página de consentimiento solo puede mostrar los scopes que pidió el cliente, así que aprobar el cliente es lo que concede el scope de escritura.

Un informe abierto, supabase/auth#2820 (abierto el 20 de septiembre de 2026; sin respuestas a fecha de 3 de octubre), dice que la solicitud de detalles de autorización de Supabase devuelve 400 para clientes públicos, offline_access o resource. Ninguno de estos casos se reprodujo en nuestras pruebas; en el código fuente de Supabase Auth que revisamos, ese 400 aparece solo para una autorización que ya no está pendiente, como una que se consulta dos veces.

¿Registro dinámico de clientes o documentos de metadatos de ID de cliente?

Usa lo que anuncia tu servidor de autorización; con Supabase hoy, eso es el registro dinámico de clientes (DCR). La especificación MCP 2025-11-25 recomienda los documentos de metadatos de ID de cliente (CIMD) cuando cliente y servidor nunca se han conocido, y mantiene DCR por compatibilidad con versiones anteriores. Los clientes deben probar primero un ID de cliente dado de antemano, luego CIMD, luego DCR, y solo después preguntar a la persona.

Cómo obtiene un ID de cliente un cliente MCP
MétodoCómo se identifica el clienteSeñal en los metadatos del servidorOpción en el formulario de ClaudeNuestro servidor, 3 de octubre de 2026
Registro previoUn ID de cliente creado de antemano, introducido a mano o integradoNo hace falta ninguna“Use your own OAuth client” (usar tu propio cliente OAuth)No se ofrece
Documento de metadatos de ID de clienteSu client_id es una URL HTTPS a un archivo JSON que aloja el cliente y que el servidor descargaclient_id_metadata_document_supported“Use Claude’s published identity (recommended)” (usar la identidad publicada de Claude, recomendado)No se anuncia
Registro dinámico de clientesEnvía sus metadatos al endpoint de registro y recibe un client_idregistration_endpoint“Register automatically” (registrar automáticamente)Se anuncia

Los metadatos de Supabase, leídos de nuevo el 3 de octubre de 2026, incluyen un endpoint de registro, S256 y el método none del endpoint de token, y ningún indicador de CIMD; su guía de MCP indica activar el registro dinámico en el panel de control.

Eso decide qué elige la gente en el formulario de conector personalizado de Claude. La documentación de Anthropic describe la opción recomendada, la identidad publicada de Claude, como un CIMD que aloja Anthropic y que el servidor debe admitir. Por eso nuestra guía pide “Sign in now” (iniciar sesión ahora), ya que ninguna de nuestras herramientas funciona sin una cuenta, y “Register automatically” (registrar automáticamente). La documentación para desarrolladores de Anthropic dice que Claude recurre a DCR cuando no se anuncia CIMD (CIMD también necesita none en token_endpoint_auth_methods_supported); no hemos probado el formulario con su opción predeterminada sin tocar, así que pedimos la elección explícita. La documentación de clientes que leímos el 2 de octubre de 2026 describe DCR para el modo de desarrollador de ChatGPT, Cursor, VS Code y Gemini CLI.

DCR tiene un coste: Anthropic señala que Claude registra un cliente nuevo en cada conexión nueva, y Supabase advierte de que el registro dinámico permite que cualquier cliente MCP se registre en tu proyecto. Registrarse, sin embargo, no concede nada por sí solo. Una persona sigue teniendo que iniciar sesión y aprobar el cliente, y el verificador comprueba la cuenta en cada solicitud. Como cualquier cliente puede registrarse con cualquier nombre, desde el 6 de octubre de 2026 nuestra página de consentimiento muestra adónde enviará a la persona al aprobar, y avisa cuando una aplicación dice llamarse ChatGPT o Claude pero no vuelve a la dirección de ese asistente.

¿Los clientes MCP siguen con la sesión iniciada cuando caduca el token de acceso?

Sí. Los tokens de acceso duran una hora, y los clientes los renuevan con tokens de renovación. Antes de confiar en eso, leímos el código fuente de Supabase Auth (master a 22 de septiembre de 2026):

  • cada intercambio de código devuelve un token de renovación, con o sin offline_access;
  • una renovación debe venir del propio cliente de la sesión, rota el token de renovación y conserva el claim client_id;
  • el hook también se ejecuta en las renovaciones (authentication_method es token_refresh), así que nuestra audiencia y nuestro scope vuelven cada vez.

Una ejecución real del 2 de octubre de 2026 coincidió: el token de renovación rotó y el token de acceso renovado funcionó. La documentación de Anthropic dice que Claude renueva tras un 401 y hasta cinco minutos antes de la caducidad, y añade offline_access cuando el servidor de autorización lo lista en scopes_supported. Los metadatos de Supabase lo listan, y nuestra comprobación que también pedía offline_access pasó.

Las sesiones aún terminan cuando:

  • la cuenta se desactiva, o el usuario se elimina o se bloquea (en nuestra ejecución real, desactivar la cuenta rechazó la sesión del navegador y los dos tokens MCP);
  • se reutiliza un token de renovación antiguo pasado el intervalo de reutilización de Supabase, de 10 segundos de forma predeterminada, lo que revoca toda la sesión;
  • se activa un límite de duración de sesión (time-box), un tiempo de inactividad máximo o “single session per user” (una sola sesión por usuario). Mantén desactivada esta última, o un inicio de sesión en el sitio web termina la sesión del asistente;
  • la persona cierra sesión en el sitio web con el logout predeterminado de Supabase, que termina todas las sesiones de la cuenta, incluida la del asistente. El nuestro lo hacía hasta el 6 de octubre de 2026, la causa probable de que una conexión de ChatGPT ya no pudiera renovar su token; ahora “Cerrar sesión” envía scope=local y termina solo la sesión de ese navegador.

Supabase no anuncia ningún endpoint de revocación (comprobado el 3 de octubre de 2026), así que desactivar la cuenta es como cortamos el acceso.

¿Por qué importa una barra final en el emisor?

Porque los emisores se comparan carácter por carácter. RFC 8414 exige que el issuer de los metadatos sea idéntico al que usó el cliente para encontrarlos, y RFC 9728 pide lo mismo para resource. Para un cliente estricto, https://auth.example.com y https://auth.example.com/ son emisores distintos.

El SDK de Python de MCP guarda el emisor como un AnyHttpUrl de pydantic, y pydantic 2.13.5, la versión de nuestro lockfile, convierte https://auth.example.com en https://auth.example.com/. Los metadatos construidos a partir de eso nombran un emisor que el servidor de autorización nunca publicó, así que nuestro servidor escribe sus propios metadatos de recurso protegido con el emisor exactamente como está configurado. El emisor de Supabase termina en una ruta, que pydantic no toca, así que esto nunca nos afectó en producción; sí nos afectaría con un emisor sin ruta, solo con el origen. Nuestra comprobación de despliegue falla si el issuer del servidor de autorización no coincide exactamente con el nuestro.

Qué hemos verificado y qué no

Nuestra primera evidencia vino de un cliente automatizado que sigue la especificación MCP. El 2 de octubre de 2026, una ejecución contra producción cubrió el 401 sin token, el registro dinámico de un cliente público, el consentimiento y el intercambio de código con resource, una renovación con rotación, tools/list con los dos tokens y la desactivación de la cuenta, que rechazó ambos tokens y la sesión del navegador. Una segunda ejecución también pidió offline_access, y las dos pasaron. Esa ejecución usó nuestra dirección anterior; el cambio a mcp.scorestarling.com tiene su propio artículo.

Desde entonces se han conectado clientes reales. La aplicación del modo de desarrollador de ChatGPT inició sesión a través de mcp.scorestarling.com y llamó a una herramienta el 3 de octubre de 2026, y Claude se conectó y ejecutó herramientas el 6 de octubre. Aún sin verificar:

  • una renovación después de que un token haya caducado de verdad: la renovación automatizada se hizo justo después de iniciar sesión, y no hemos rastreado ninguna de un cliente real;
  • los ajustes de sesión alojados de Supabase, que no hemos vuelto a leer.

Fuentes

  1. Authorization, MCP specification version 2025-11-25, Model Context Protocol
  2. RFC 9728: OAuth 2.0 Protected Resource Metadata, IETF
  3. RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, §3.1, IETF
  4. RFC 8707: Resource Indicators for OAuth 2.0, IETF
  5. RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol, IETF
  6. RFC 8414: OAuth 2.0 Authorization Server Metadata, IETF
  7. OAuth Client ID Metadata Document (Internet-Draft), grupo de trabajo OAuth del IETF
  8. Authentication for connectors, Anthropic
  9. Add a connector that isn’t in the directory, Anthropic
  10. OAuth 2.1 Flows, Supabase
  11. Custom Access Token Hook, Supabase
  12. Model Context Protocol (MCP) Authentication, Supabase
  13. User sessions, Supabase
  14. supabase/auth issue #2820, GitHub

Preguntas y respuestas

¿Necesita un servidor MCP remoto su propio servidor de autorización?

No. La especificación de autorización de MCP permite que el servidor de autorización sea un servicio aparte. El servidor MCP publica metadatos RFC 9728 que lo nombran y luego valida los tokens que ese servicio emite, incluida su audiencia. El nuestro apunta a Supabase Auth y solo verifica tokens.

¿Debe el 401 de un servidor MCP incluir el error invalid_token cuando no se envió ningún token?

No. RFC 6750 §3.1 dice que una solicitud sin credenciales no debería recibir ningún código de error. Envía en su lugar resource_metadata y una indicación de scope, y reserva invalid_token para un token que se envió y fue rechazado, donde le indica al cliente que lo renueve.

¿Puede Supabase Auth emitir scopes personalizados para un servidor MCP?

A fecha de 3 de octubre de 2026, no: la documentación de OAuth 2.1 de Supabase dice que los scopes personalizados no se admiten. Pedimos openid email profile y añadimos nuestro propio scope y la audiencia de MCP en un hook de token de acceso personalizado, que además se ejecuta en cada renovación.

¿Qué deben elegir las personas en el formulario de conector personalizado de Claude si el servidor no admite CIMD?

Elegir “Register automatically” (registrar automáticamente) en “OAuth client” (cliente OAuth), que usa el registro dinámico de clientes, y “Sign in now” (iniciar sesión ahora) en “Authentication” (autenticación) si todas las herramientas necesitan una cuenta. La identidad publicada que recomienda Claude es un documento de metadatos de ID de cliente, y el servidor debe admitirlo. Nuestros pasos de Claude muestran las dos opciones.

¿Necesito el scope offline_access para obtener tokens de renovación de Supabase?

En nuestras pruebas, no. Supabase devolvió un token de renovación en cada intercambio de código sin él, según su código fuente y en ejecuciones reales del 2 de octubre de 2026. Algunos clientes lo piden igualmente; la documentación de Anthropic dice que Claude lo hace cuando el servidor de autorización lo lista, y nuestra comprobación con él también pasó.

Trae cualquier música Llévate una partitura