BlogEngenharia

OAuth em servidor MCP remoto: o que aprendemos ao lançá-lo

Notas de um servidor MCP em produção que usa o Supabase Auth como servidor de autorização: o que a especificação pede, o que fizemos errado e o que ainda não testamos.

Escrito por
Publicado
Atualizado
Tamanho
12 min de leitura

A resposta curta

Um servidor MCP remoto é um recurso protegido do OAuth. Sem token, ele responde 401 e aponta para os metadados da RFC 9728; o cliente segue esse caminho até o servidor de autorização, se registra, faz o login da pessoa com PKCE e o parâmetro resource e volta com um bearer token emitido para a URL do servidor. Rodamos isso em produção com o Supabase Auth. Três detalhes deram trabalho: um 401 com dica de escopo e sem código de erro, um hook de token que acrescenta a nossa audiência e o nosso escopo aos tokens do Supabase, e o registro dinâmico de clientes, já que o nosso servidor de autorização não oferece documentos de metadados do ID do cliente.

Quem faz o quê quando um servidor MCP usa OAuth?

O servidor MCP tem a tarefa menor: aceitar ou rejeitar bearer tokens. A especificação de autorização do MCP (versão 2025-11-25) o define como servidor de recursos do OAuth 2.1 e o assistente como cliente OAuth, e permite que o servidor de autorização, que faz o login da pessoa e emite os tokens, seja um serviço separado.

No nosso caso, é separado. O servidor OAuth 2.1 do Supabase Auth emite todos os tokens, o nosso site fornece a página de login e de consentimento, e o endpoint em https://mcp.scorestarling.com/mcp só verifica tokens. Ele roda Streamable HTTP sem sessões MCP e confere o token a cada requisição. O ScoreStarling transforma gravações em partituras editáveis, e esse endpoint é o caminho pelo qual os assistentes chegam a ele; o guia de configuração mostra os passos que as pessoas seguem.

O que acontece quando um cliente MCP se conecta?

O cliente começa só com a URL. Duas respostas do nosso servidor, mostradas como estavam em 3 de outubro de 2026, dizem a ele onde fazer o login; mais seis passos levam a um token. Primeiro, uma requisição sem token recebe um 401 cujo cabeçalho WWW-Authenticate informa os metadados e os escopos a 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, o cliente busca esses metadados. A RFC 9728 monta o endereço inserindo /.well-known/oauth-protected-resource entre o host e o caminho, e exige que resource seja idêntico à URL que o cliente está usando; do contrário, o cliente deve parar. A referência do nosso projeto Supabase está substituída aqui:

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

Os outros seis passos:

  1. O cliente lê os metadados do servidor de autorização. O emissor do Supabase tem um caminho, então a RFC 8414 põe o segmento well-known antes dele: https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. O cliente confere se o issuer coincide exatamente e se code_challenge_methods_supported lista S256; a especificação do MCP manda parar se esse campo estiver ausente.

  2. Ele envia por POST os próprios metadados ao registration_endpoint anunciado (registro dinâmico de clientes, RFC 7591) e recebe um client_id. O nosso cliente de teste se registra como cliente público, sem segredo; a documentação da Anthropic diz que o Claude também.

  3. Ele abre o navegador no endpoint de autorização com um desafio PKCE S256, o escopo do 401 e resource=https://mcp.scorestarling.com/mcp (RFC 8707). O Supabase entrega o navegador à nossa página de consentimento, que faz o login da pessoa, se for preciso, e pede que ela aprove ou negue o cliente.

  4. Ele troca o código usando o verificador PKCE e o mesmo resource e recebe um token de acesso que dura uma hora, mais um token de atualização.

  5. Ele envia Authorization: Bearer … em toda requisição MCP. Nós conferimos a assinatura com as chaves publicadas do emissor, o emissor, a audiência, a validade e o escopo de escrita, e depois se a conta não foi desativada.

  6. Quando o token expira, ou respondemos 401 com invalid_token, ele renova, de novo com resource, e recebe um novo token de acesso e um novo token de atualização.

Os clientes devem enviar resource nas requisições de autorização e de token mesmo que o servidor de autorização o ignore. A documentação do fluxo OAuth do Supabase não menciona o parâmetro (conferido em 3 de outubro de 2026). O Supabase o aceitou em todas as nossas execuções reais, e a audiência dos nossos tokens vem de um hook.

O que o cabeçalho WWW-Authenticate do 401 deve conter?

Quando nenhum token foi enviado: onde estão os metadados, quais escopos pedir e nenhum código de erro. A RFC 6750 §3.1 diz que uma requisição sem nenhuma informação de autenticação não deve receber código de erro. O invalid_token é para um token que foi enviado e falhou, e diz ao cliente que ele pode obter um novo e tentar de novo. A especificação do MCP acrescenta que o 401 deve trazer uma dica de scope, que os clientes devem tratar como os escopos a pedir.

Descobrimos que o nosso próprio servidor errava nisso enquanto escrevíamos o guia de configuração. No MCP Python SDK 1.30.0, o RequireAuthMiddleware responde a toda requisição não autenticada com error="invalid_token" e sem escopo, então um cliente que nunca tinha enviado um token ouvia que o token dele era inválido. Hoje um pequeno middleware ASGI em volta da rota do MCP reescreve só os 401 do SDK; o SDK continua decidindo quem entra, e a resposta 403 insufficient_scope dele fica 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"

A dica cita openid email profile, os escopos que o Supabase pode conceder, de acordo com scopes_supported. A nossa verificação de deploy agora falha se um desafio sem token trouxer um código de erro. A correção chegou à produção em 2 de outubro de 2026, em até quatro minutos depois do merge. A documentação de conectores da Anthropic acrescenta duas regras para o Claude: o login só começa a partir de um 401 (ele ignora o WWW-Authenticate em uma resposta 200), e ele usa apenas a primeira entrada de authorization_servers.

Como pôr uma audiência e um escopo do MCP nos tokens do Supabase?

Com um hook de token de acesso personalizado. A especificação do MCP diz que um servidor deve aceitar apenas tokens emitidos para ele, o que na prática significa conferir o aud, e queríamos um escopo de escrita próprio; a documentação do Supabase diz que o servidor OAuth dele não aceita escopos personalizados (conferido em 3 de outubro de 2026). O hook é uma função do Postgres que o Supabase Auth executa antes de emitir um token e que pode reescrever claims. O nosso, um pouco simplificado a partir da nossa migração:

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;

O lado do MCP confere essas claims com o PyJWT e depois aplica duas regras próprias:

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 é a URL do MCP, mais a antiga durante uma mudança. O teste do ID do cliente deixa de fora o login do próprio site: um token de sessão do navegador do mesmo projeto Supabase tem o mesmo emissor e as mesmas chaves, mas não tem client_id, então nunca recebe a audiência do MCP.

O hook tem dois custos. Ele carimba uma única audiência fixa em vez de seguir o resource do cliente, o que funciona enquanto um servidor de autorização atende a um só servidor MCP. E a nossa página de consentimento só pode mostrar os escopos que o cliente pediu, então aprovar o cliente é o que concede o escopo de escrita.

Um relatório em aberto, supabase/auth#2820 (aberto em 20 de setembro de 2026; sem respostas até 3 de outubro), diz que a requisição de detalhes de autorização do Supabase devolve 400 para clientes públicos, offline_access ou resource. Nenhum desses casos se reproduziu para nós; no código-fonte do Supabase Auth que revisamos, esse 400 aparece apenas para uma autorização que já não está pendente, como uma buscada duas vezes.

Registro dinâmico de clientes ou documentos de metadados do ID do cliente?

Use o que o seu servidor de autorização anuncia; com o Supabase hoje, isso é o registro dinâmico de clientes (DCR). A especificação do MCP 2025-11-25 recomenda documentos de metadados do ID do cliente (CIMD) quando cliente e servidor nunca se encontraram e mantém o DCR por compatibilidade com versões anteriores. Os clientes devem tentar primeiro um ID de cliente fornecido de antemão, depois o CIMD, depois o DCR, e só então perguntar à pessoa.

Como um cliente MCP obtém um ID de cliente
AbordagemComo o cliente se identificaSinal nos metadados do servidorOpção no formulário do ClaudeNosso servidor, 3 de outubro de 2026
Pré-registroUm ID de cliente criado de antemão, digitado à mão ou embutidoNenhum necessário“Use your own OAuth client” [Usar o seu próprio cliente OAuth]Não oferecido
Documento de metadados do ID do clienteO client_id dele é uma URL HTTPS para um arquivo JSON que o cliente hospeda e que o servidor buscaclient_id_metadata_document_supported“Use Claude’s published identity (recommended)” [Usar a identidade publicada do Claude (recomendado)]Não anunciado
Registro dinâmico de clientesEle envia seus metadados ao endpoint de registro e recebe um client_idregistration_endpoint“Register automatically” [Registrar automaticamente]Anunciado

Os metadados do Supabase, lidos de novo em 3 de outubro de 2026, listam um endpoint de registro, S256 e o método none do endpoint de token, e nenhum indicador de CIMD; o guia de MCP dele manda ativar o registro dinâmico no painel.

Isso decide o que as pessoas escolhem no formulário de conector personalizado do Claude. A documentação da Anthropic descreve a opção recomendada, a identidade publicada do Claude, como um CIMD que a Anthropic hospeda e que o servidor precisa aceitar. Por isso o nosso guia pede “Sign in now” [Entrar agora], já que nenhuma das nossas ferramentas funciona sem conta, e “Register automatically” [Registrar automaticamente]. A documentação para desenvolvedores da Anthropic diz que o Claude recorre ao DCR quando o CIMD não é anunciado (o CIMD também exige none em token_endpoint_auth_methods_supported); não testamos o formulário com a opção padrão deixada como está, então pedimos a escolha explícita. A documentação de clientes que lemos em 2 de outubro de 2026 descreve o DCR para o modo de desenvolvedor do ChatGPT, o Cursor, o VS Code e o Gemini CLI.

O DCR tem um custo: a Anthropic observa que o Claude registra um cliente novo a cada conexão nova, e o Supabase avisa que o registro dinâmico deixa qualquer cliente MCP se registrar no seu projeto. Só registrar, porém, não concede nada. Uma pessoa ainda precisa entrar e aprovar o cliente, e o verificador confere a conta a cada requisição. Como qualquer cliente pode se registrar com qualquer nome, a nossa página de consentimento mostra desde 6 de outubro de 2026 para onde a aprovação leva a pessoa, e avisa quando um app se chama ChatGPT ou Claude mas não volta ao endereço desse assistente.

Os clientes MCP continuam conectados depois que o token de acesso expira?

Sim. Os tokens de acesso duram uma hora, e os clientes os renovam com tokens de atualização. Antes de contar com isso, lemos o código-fonte do Supabase Auth (master em 22 de setembro de 2026):

  • toda troca de código devolve um token de atualização, com ou sem offline_access;
  • uma renovação precisa vir do próprio cliente da sessão, faz a rotação do token de atualização e mantém a claim client_id;
  • o hook também roda nas renovações (authentication_method é token_refresh), então a nossa audiência e o nosso escopo voltam a cada vez.

Uma execução real em 2 de outubro de 2026 confirmou isso: o token de atualização foi rotacionado, e o token de acesso renovado funcionou. A documentação da Anthropic diz que o Claude renova depois de um 401 e até cinco minutos antes de expirar, e acrescenta offline_access quando o servidor de autorização o lista em scopes_supported. Os metadados do Supabase o listam, e a nossa verificação que também pediu offline_access passou.

As sessões ainda terminam quando:

  • a conta é desativada, ou o usuário é excluído ou banido (na nossa execução real, desativar a conta rejeitou a sessão do navegador e os dois tokens do MCP);
  • um token de atualização antigo é reutilizado depois do intervalo de reutilização do Supabase, 10 segundos por padrão, o que revoga a sessão inteira;
  • passa a valer um limite de duração, um tempo limite de inatividade ou “single session per user” [uma sessão por usuário]. Deixe o último desativado, ou o login no site encerra a sessão do assistente;
  • a pessoa sai do site com o logout padrão do Supabase, que encerra todas as sessões da conta, inclusive a do assistente. O nosso fazia isso até 6 de outubro de 2026, o provável motivo de uma conexão do ChatGPT não conseguir mais renovar o token; agora “Sair” envia scope=local e encerra só a sessão daquele navegador.

O Supabase não anuncia nenhum endpoint de revogação (conferido em 3 de outubro de 2026), então desativar a conta é a nossa forma de cortar o acesso.

Por que uma barra no final do emissor faz diferença?

Porque os emissores são comparados caractere por caractere. A RFC 8414 exige que o issuer nos metadados seja idêntico ao que o cliente usou para encontrá-los, e a RFC 9728 pede o mesmo de resource. Para um cliente rigoroso, https://auth.example.com e https://auth.example.com/ são emissores diferentes.

O MCP Python SDK guarda o emissor como um AnyHttpUrl do pydantic, e o pydantic 2.13.5, a versão do nosso arquivo de lock, transforma https://auth.example.com em https://auth.example.com/. Metadados montados a partir disso nomeiam um emissor que o servidor de autorização nunca publicou, então o nosso servidor escreve os próprios metadados de recurso protegido, com o emissor exatamente como foi configurado. O emissor do Supabase termina em um caminho, que o pydantic deixa como está, então isso nunca nos atingiu em produção; atingiria com um emissor que fosse só a origem, sem caminho. A nossa verificação de deploy falha se o issuer do servidor de autorização não for exatamente igual ao nosso.

O que verificamos e o que não verificamos

Nossa primeira evidência veio de um cliente automatizado por script que segue a especificação do MCP. Em 2 de outubro de 2026, uma execução na produção cobriu o 401 sem token, o registro dinâmico de um cliente público, o consentimento e a troca de código com resource, uma renovação com rotação, tools/list com os dois tokens e a desativação da conta, que rejeitou os dois tokens e a sessão do navegador. Uma segunda execução também pediu offline_access, e as duas passaram. Essa execução usou o nosso endereço anterior; a mudança para mcp.scorestarling.com tem um artigo próprio.

Clientes reais já se conectaram desde então. O app do ChatGPT em modo de desenvolvedor entrou por mcp.scorestarling.com e chamou uma ferramenta em 3 de outubro de 2026, e o Claude se conectou e rodou ferramentas em 6 de outubro. Ainda não verificado:

  • uma renovação depois que um token realmente expirou: a renovação do script rodou logo depois do login, e ainda não rastreamos uma de um cliente real;
  • as configurações de sessão do Supabase na versão hospedada, que ainda não relemos para conferir.

Fontes

  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), IETF OAuth Working Group
  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

Perguntas e respostas

Um servidor MCP remoto precisa do próprio servidor de autorização?

Não. A especificação de autorização do MCP permite que o servidor de autorização seja um serviço separado. O servidor MCP publica metadados da RFC 9728 que apontam para o servidor de autorização e depois valida os tokens emitidos por ele, inclusive a audiência. O nosso aponta para o Supabase Auth e só verifica tokens.

O 401 de um servidor MCP deve incluir o erro invalid_token quando nenhum token foi enviado?

Não. A RFC 6750 §3.1 diz que uma requisição sem credenciais não deve receber código de erro. Envie resource_metadata e uma dica de scope, e guarde o invalid_token para um token que foi enviado e rejeitado, caso em que ele diz ao cliente para renovar o token.

O Supabase Auth consegue emitir escopos personalizados para um servidor MCP?

Não, em 3 de outubro de 2026: a documentação de OAuth 2.1 do Supabase diz que escopos personalizados não são compatíveis. Pedimos openid email profile e acrescentamos o nosso próprio escopo e a audiência do MCP em um hook de token de acesso personalizado, que também roda a cada renovação.

O que escolher no formulário de conector personalizado do Claude quando o servidor não é compatível com CIMD?

Escolha “Register automatically” [Registrar automaticamente] em “OAuth client” [Cliente OAuth], que usa o registro dinâmico de clientes, e “Sign in now” [Entrar agora] em “Authentication” [Autenticação] se toda ferramenta precisa de uma conta. A identidade publicada recomendada pelo Claude é um documento de metadados do ID do cliente, e o servidor precisa ser compatível com ele. Os nossos passos do Claude mostram as duas escolhas.

Preciso do escopo offline_access para receber tokens de atualização do Supabase?

Nos nossos testes, não. O Supabase devolveu um token de atualização em toda troca de código, mesmo sem ele, tanto no código-fonte quanto em execuções reais de 2 de outubro de 2026. Alguns clientes pedem o escopo mesmo assim; a documentação da Anthropic diz que o Claude pede quando o servidor de autorização o lista, e o nosso teste com ele também passou.

Traga qualquer música Saia com uma partitura