Criando um MCP para uma API com OAuth
Olá meus Unicórnios! 🦄✨
Sabe quando você acha que vai ser meia hora de trabalho? 😅 Pois é. Eu queria conectar o Claude a uma API que já existe, já funciona e já tem OAuth. Achei que seria só apontar o servidor MCP para a API e pronto. Só que o OAuth que o Claude exige e o OAuth que a maioria das APIs tem são duas coisas diferentes — e ninguém escreve isso em lugar nenhum.
Este artigo é o caminho inteiro dessa ponte: o que o Claude pede, o que a API tem, e o pedacinho de código que reconcilia os dois sem você precisar mudar uma linha da API. No fim tem um repositório que roda sozinho na sua máquina, com uma API de mentira que se comporta como as de verdade — inclusive nas partes chatas.
🤔 O problema que ninguém avisa
Quando você adiciona um conector personalizado no Claude, ele não pede um token para você colar. Ele faz uma coisa muito mais ambiciosa: trata o seu servidor como um Authorization Server OAuth completo. Isso quer dizer, em ordem:
- Ele baixa
/.well-known/oauth-authorization-serverpara descobrir onde ficam seus endpoints. - Ele se registra sozinho — Dynamic Client
Registration — inventando um
client_idna hora. - Ele abre
/authorizecom PKCE. - Ele troca o código em
/token.
Agora olhe a sua API. Ela tem um app cadastrado à mão num painel, com um
client_id fixo, um client_secret que é segredo de
servidor, e um redirect_uri que ela compara por igualdade exata.
Ela não tem registro dinâmico. Ela não vai passar a ter.
A saída é o seu servidor MCP fazer três papéis ao mesmo tempo, e essa é a ideia que destrava tudo:
1. servidor MCP — as ferramentas, em POST /mcp
2. Authorization Server — para o Claude: /authorize, /token, /register, /.well-known/…
3. cliente OAuth — da sua API, através de GET /callback
Os papéis 2 e 3 juntos são a ponte. O Claude fala com o papel 2 e acha que está falando com um Authorization Server de verdade. A sua API fala com o papel 3 e acha que está falando com um app comum cadastrado nela. Nenhum dos dois descobre. 😄
🌉 O fluxo inteiro, uma vez só
Vale a pena ler devagar, porque tudo depois disto é detalhe de implementação. Chamei de [C↔M] a conversa entre Claude e o meu servidor, e de [M↔A] a conversa entre o meu servidor e a API:
1. [C↔M] Claude lê GET /.well-known/oauth-authorization-server
2. [C↔M] Claude faz POST /register (inventa um client_id próprio)
3. [C↔M] Claude abre GET /authorize?…&code_challenge=… (PKCE)
→ guardo o endereço de volta dele + o state dele + o desafio PKCE
→ redireciono o usuário para a tela de login da API
4. [M↔A] Tela da API: o usuário informa login e senha e clica em Autorizar
5. [M↔A] API → GET /callback?code=<código-da-API>&state=<minha-sessão>
→ troco o código por tokens (agora sim, com o client_secret)
→ gero um código MEU e devolvo o Claude ao endereço dele
6. [C↔M] Claude troca POST /token (com o verifier do PKCE)
→ emito um access_token MEU, opaco, mapeado aos tokens da API
7. Uso: Claude → POST /mcp com Bearer <token-meu>
→ a ferramenta usa o token da API que está mapeado
Repare no detalhe cruel: existem dois state e
dois pares de token nessa história, e eles nunca se
encontram. O state do Claude fica guardado comigo; o que viaja até a
API é o meu identificador de sessão. O token que o Claude recebe é meu; o token
da API nunca sai do meu processo.
Isso mesmo, duas! 🤯 Duas identidades paralelas, cada uma achando que está numa conversa normal.
🔑 Por que não repassar o token da API direto
Foi a primeira coisa que me passou pela cabeça: por que não pego o token que a API me deu e entrego ao Claude? Seria bem menos código.
Porque seria entregar credencial alheia a um terceiro. O token da API dá acesso à conta inteira daquela pessoa, e o meu servidor não tem autoridade nenhuma para sair distribuindo isso. Se amanhã eu quiser revogar o acesso de um conector específico, ou trocar o escopo, ou saber quem chamou o quê — não dá, porque a credencial já saiu da minha mão.
Então eu emito um token opaco, que só o meu servidor sabe traduzir, e guardo o mapeamento:
token-que-o-Claude-usa → { token da API, refresh da API, quando vence }
São três coisas que a ponte precisa guardar. Neste exemplo elas vivem em
Map na memória, para o repositório rodar sem banco. Em produção, o
mesmo arquivo vira SQLite, MySQL, o que você quiser — as funções continuam com a
mesma cara, e nada mais no código muda de lugar:
/*
* ARMAZENAMENTO — as tres tabelas que a ponte OAuth precisa.
*
* Aqui e um Map em memoria, para o exemplo rodar sem banco. Em producao troque
* por SQLite/MySQL/Postgres mantendo as MESMAS funcoes: o resto do codigo nao
* muda de lugar nenhum.
*
* clientes — os apps que o Claude registrou sozinho (Dynamic Client
* Registration). Sim, o Claude inventa um client_id proprio a
* cada conector novo; e o servidor MCP tem de aceitar.
*
* sessoes — a autorizacao EM VOO. Vive entre o "o Claude mandou o usuario
* para o login" e o "o usuario voltou autorizado". TTL curto.
*
* tokens — o mapeamento que faz tudo funcionar depois:
* token-que-o-Claude-usa -> tokens-da-API-externa.
* O Claude nunca ve o token da API externa. Nem deve.
*/
const clientes = new Map();
const sessoes = new Map();
const tokens = new Map();
🚪 Passo 3: o Claude bate na porta
Aqui começa o código de verdade. O SDK do MCP já entrega os endpoints prontos
(/authorize, /token, /register, os
metadados) — o que você escreve é a classe que decide o que cada um faz. Este é o
método que atende o passo 3:
/* PASSO 3 — o Claude abriu /authorize. Guardamos o que ele mandou e
empurramos o usuario para a tela de login da API externa. */
async authorize(cliente, params, res) {
const idSessao = gerarSegredo();
criarSessao({
id_sessao: idSessao,
client_id: cliente.client_id,
// O endereco de volta do Claude e o state DELE: guardados, nao repassados.
redirect_uri_do_claude: params.redirectUri,
state_do_claude: params.state ?? null,
// O desafio PKCE do Claude. Quem confere e o SDK, no /token; nosso papel
// e devolver este valor quando ele pedir (challengeForAuthorizationCode).
code_challenge: params.codeChallenge,
codigo_mcp: null,
tokens_externos: null,
expira_em: agora() + SEGUNDOS_DA_SESSAO,
});
console.log(`[oauth] authorize: sessao ${idSessao.slice(0, 8)}… criada, mandando para o login`);
// O `state` que a API externa recebe e o NOSSO id de sessao. E assim que o
// callback, la na frente, descobre de qual autorizacao ele e a volta.
res.redirect(montarUrlDeAutorizacao(idSessao, this.redirectUriDaPonte));
}
Três coisas moram nesse método, e as três importam:
- O endereço de volta do Claude vira dado guardado, não é repassado. A API nunca vai saber que existe um Claude do outro lado.
- O
stateque a API recebe é o meu id de sessão. É por isso que o/callback, lá na frente, consegue descobrir de qual autorização aquela volta é — sem nenhum cookie. - O desafio PKCE fica guardado para o SDK conferir depois. Eu não valido nada aqui; só preciso saber devolver o valor quando ele pedir.
🎯 Passo 5: a volta, e a armadilha que mais custa tempo
O usuário digitou a senha, clicou em Autorizar, e a API mandou o navegador de
volta para o meu /callback. Aqui acontece a única parte da história
em que o client_secret entra em campo — servidor para servidor, longe
do navegador:
/* PASSO 5 — o usuario autorizou e a API externa devolveu ?code= no nosso
/callback. Trocamos por tokens de verdade e emitimos um codigo NOSSO. */
async concluirCallback(idSessao, codigoExterno) {
const sessao = buscarSessao(idSessao);
if (!sessao) throw new Error("sessao_desconhecida");
if (sessao.expira_em < agora()) {
apagarSessao(idSessao);
throw new Error("sessao_expirada");
}
// Aqui — e so aqui — o client_secret entra em campo. Servidor para
// servidor. Ele nunca passou pelo navegador nem pelo Claude.
const tokensExternos = await trocarCodigoPorTokens(
codigoExterno,
this.clientSecret,
this.redirectUriDaPonte
);
console.log("[oauth] callback: tokens da API externa obtidos");
// Um codigo de autorizacao NOSSO, que so este servidor sabe traduzir.
// Mandar o code da API externa para o Claude seria vazar credencial alheia.
const codigoMcp = gerarSegredo();
atualizarSessao(idSessao, { tokens_externos: tokensExternos, codigo_mcp: codigoMcp });
const volta = new URL(sessao.redirect_uri_do_claude);
volta.searchParams.set("code", codigoMcp);
// O state do Claude volta agora, intacto. Ele conferiu que e o mesmo.
if (sessao.state_do_claude) volta.searchParams.set("state", sessao.state_do_claude);
return volta.toString();
}
E agora a armadilha. Se você só for ler um pedaço deste artigo, leia este. 🙏
O redirect_uri é comparado como texto. Não como
endereço, não como host, não como "dá no mesmo". Como texto. Eu perdi um tempo
absurdo com isso, porque o erro acontece antes da tela de login — você
nem chega a digitar a senha, e a mensagem não ajuda nada.
Dá para reproduzir em trinta segundos com o repositório do artigo: suba o
servidor trocando localhost por 127.0.0.1. Mesmo
endereço, mesma máquina, mesma porta:
URL_PUBLICA="http://127.0.0.1:3011" npm start
Começa o fluxo, e a API responde assim — esta saída é real, copiada da execução:
O MCP mandou o usuario para:
http://localhost:3012/oauth/authorize?client_id=exemplo_mcp&redirect_uri=http%3A%2F%2F127.0.0.1%3A3013%2Fcallback&state=<sessao>
E a API externa respondeu HTTP 400:
redirect_uri nao confere.
Recebido: http://127.0.0.1:3013/callback
Cadastrado: http://localhost:3011/callback
Uma barra a mais no fim faz o mesmo estrago. Por isso o endereço nasce de uma variável só, e todo o resto deriva dela:
const ponte = new PonteOAuth({
clientSecret: CLIENT_SECRET,
// Derivado da URL publica: assim basta trocar uma variavel para alternar
// entre o tunel de teste e o dominio de producao.
redirectUriDaPonte: `${URL_PUBLICA}/callback`,
});
Escrever o /callback em dois lugares — um no código, outro no
cadastro da API — é convite para os dois divergirem no dia em que você mudar de
domínio. E aí volta o 400. 😩
🎫 Passo 7: o token que o Claude vai usar
O Claude recebeu o meu código de autorização e agora quer trocá-lo por um token. Duas funções cuidam disso, e a primeira é curtinha:
/* O SDK pergunta qual era o desafio PKCE daquele code, para conferir com o
verifier que o Claude mandou no /token. */
async challengeForAuthorizationCode(_cliente, codigoDeAutorizacao) {
const sessao = buscarSessaoPorCodigo(codigoDeAutorizacao);
// Tem de ser InvalidGrantError, e nao um Error qualquer: o SDK traduz este
// tipo para HTTP 400. Um Error generico viraria 500 e o Claude mostraria
// "erro do servidor" em vez de "autorizacao invalida".
if (!sessao) throw new InvalidGrantError("Codigo de autorizacao invalido ou expirado.");
return sessao.code_challenge;
}
/* PASSO 7 — o Claude troca o codigo pelo token que ele vai usar de verdade. */
async exchangeAuthorizationCode(_cliente, codigoDeAutorizacao) {
const sessao = buscarSessaoPorCodigo(codigoDeAutorizacao);
if (!sessao?.tokens_externos) {
throw new InvalidGrantError("Codigo de autorizacao invalido ou expirado.");
}
const tokenMcp = gerarSegredo();
// O mapeamento que sustenta todo o resto: um token opaco para o Claude,
// os tokens da API externa guardados aqui dentro.
salvarToken({
token_mcp: tokenMcp,
...sessao.tokens_externos,
client_id: sessao.client_id,
criado_em: agora(),
});
apagarSessao(sessao.id_sessao); // a sessao em voo cumpriu seu papel
console.log("[oauth] token: access_token do MCP emitido para o Claude");
return {
access_token: tokenMcp,
token_type: "bearer",
expires_in: SEGUNDOS_DO_TOKEN_MCP,
scope: "notas",
};
}
Repare no comentário do InvalidGrantError, porque essa foi uma
lição que eu aprendi errando. O tipo do erro que você lança decide o
código HTTP que o Claude recebe. Lançar um Error comum ali
vira HTTP 500, e o usuário vê "erro no servidor" — quando o
problema era só um código expirado, que uma reconexão resolveria.
O mesmo vale, com mais força ainda, na verificação de cada chamada:
/* Toda chamada ao /mcp passa por aqui antes de virar ferramenta. */
async verifyAccessToken(token) {
const registro = buscarToken(token);
// InvalidTokenError e o unico tipo que o middleware do SDK traduz para 401
// com o cabecalho WWW-Authenticate — que e o sinal que faz o Claude
// oferecer "reconectar". Um Error comum viraria 500 e o usuario ficaria
// olhando uma falha sem saida.
if (!registro) throw new InvalidTokenError("Token invalido. Conecte a conta novamente.");
return {
token,
clientId: registro.client_id,
scopes: ["notas"],
expiresAt: registro.criado_em + SEGUNDOS_DO_TOKEN_MCP,
// O que for para `extra` chega inteiro na ferramenta, e e assim que ela
// descobre de quem sao os tokens da API externa.
extra: { tokenMcp: token },
};
}
InvalidTokenError é o único tipo que o middleware do SDK traduz
para 401 com o cabeçalho WWW-Authenticate — e é
esse cabeçalho que faz o Claude oferecer "reconectar" ao usuário. Com um
Error genérico ali, a pessoa toma um 500 e fica sem saída: não é
oferecida reconexão nenhuma, porque o Claude não recebeu o sinal.
É um detalhe de três palavras no throw que decide se a
experiência tem conserto ou não.
♻️ O refresh que ninguém vê
Tokens de API vencem. O da minha API de demonstração vence em 60 segundos, de propósito, para dar para ver acontecendo — nas de verdade costuma ser 24 horas. Quando vence, a API responde 401 e a chamada falha.
A tentação é tratar isso na ferramenta. Não trate: a ferramenta só quer as notas. O lugar certo é o cliente HTTP, num lugar só, e aí o resto do código inteiro nem fica sabendo que existe renovação de token:
export async function chamarApi(contexto, metodo, caminho, corpo) {
const registro = buscarToken(contexto.tokenMcp);
if (!registro) throw new ErroDaApi(401, "Sessao expirada. Conecte a conta novamente.");
console.log(`[api] --> ${metodo} ${caminho}`);
let resposta = await requisitar(caminho, metodo, registro.token_externo, corpo);
if (resposta.status === 401) {
// Uma vez so. Se o refresh tambem falhar, o erro sobe e a ferramenta pede
// reconexao — repetir em laco aqui daria um servidor girando em falso.
console.log("[api] 401 — renovando o token da API externa e repetindo");
const renovados = await renovarTokens(registro.refresh_externo, contexto.clientSecret);
// Gravar e obrigatorio: o refresh_token rotaciona a cada uso. Sem gravar,
// o proximo refresh usa um token ja morto e o usuario tem de logar de novo.
atualizarTokensExternos(contexto.tokenMcp, renovados);
resposta = await requisitar(caminho, metodo, renovados.token_externo, corpo);
Duas decisões aí que eu defendo com carinho:
Renova uma vez só. Se o refresh também falhar, o erro sobe e a ferramenta pede reconexão. Repetir em laço aqui daria um servidor girando em falso, martelando a API com credencial morta.
Gravar o token renovado é obrigatório. O
refresh_token normalmente rotaciona: cada uso devolve um
novo e mata o anterior. Se você renovar e não gravar, funciona lindamente
uma vez — e na seguinte o refresh usa um token que já morreu, a
renovação falha, e o usuário é obrigado a logar de novo sem entender por quê.
Esse é o tipo de bug que só aparece em produção, no segundo dia. 😳
E funciona? Funciona. Esta é a saída real do terminal, com o token vencido no meio do caminho:
[ferramenta] listar_notas
[api] --> GET /notas
[api] 401 — renovando o token da API externa e repetindo
[api] <-- GET /notas HTTP 200
HTTP POST /mcp -> 200
Quem chamou recebeu as notas normalmente. Nem soube que o token dele tinha vencido no meio do caminho. 🎉
🧰 As ferramentas: o que elas não recebem
Agora a parte que o Claude enxerga. E o mais importante aqui é uma ausência:
servidor.registerTool(
"listar_notas",
{
title: "Listar Notas",
description: "Lista as notas da conta conectada. Somente leitura.",
inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
async (_args, extra) => {
console.log("[ferramenta] listar_notas");
try {
const contexto = contextoDoPedido(extra, clientSecret);
const dados = await chamarApi(contexto, "GET", "/notas");
return {
content: [{ type: "text", text: JSON.stringify(dados, null, 2) }],
// structuredContent tem de ser um OBJETO. Um array cru aqui e
// rejeitado pelo protocolo — por isso a lista vai dentro de `Dados`.
structuredContent: dados,
};
} catch (erro) {
return erroAmigavel(erro);
}
}
);
Repare que listar_notas não recebe nenhum parâmetro dizendo de
quem são as notas. Nenhum usuario, nenhum conta_id.
O dono sai do token, e o token saiu da autenticação.
Uma ferramenta com um argumento usuario seria um convite a ler a
conta alheia — bastaria pedir ao Claude "liste as notas do fulano" e torcer. Se a
sua API exige um identificador de conta no cabeçalho, ele tem que sair do que
você guardou junto do token, nunca do que o modelo escreveu no argumento.
O outro ponto é o readOnlyHint, no registro da ferramenta de
escrita:
servidor.registerTool(
"criar_nota",
{
title: "Criar Nota",
description: "Cria uma nota nova na conta conectada.",
inputSchema: {
titulo: z.string().min(1).describe("Titulo da nota."),
texto: z.string().optional().describe("Corpo da nota."),
},
// readOnlyHint:false e o que faz o Claude PEDIR CONFIRMACAO antes de
// executar. Mentir aqui (marcar escrita como leitura) tira do usuario a
// chance de dizer nao.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
async (args, extra) => {
É esse false que faz o Claude pedir confirmação
antes de executar. Marcar uma escrita como leitura tira do usuário a chance de
dizer não — e você não vai querer descobrir isso depois de uma ferramenta ter
criado trinta registros porque o modelo entendeu o pedido de um jeito criativo.
Mantenha esses avisos honestos.
Ah, e um detalhe que custa uma tarde se você não souber:
structuredContent tem de ser um objeto, nunca um
array cru. Devolver [{…}, {…}] ali é rejeitado pelo protocolo. Por
isso a lista vai embrulhada dentro de uma chave.
🔌 Juntando: o servidor
Com as peças prontas, o servidor é quase burocrático — e é aqui que os três papéis aparecem lado a lado, cada um em três linhas:
// Papel 2: publica /.well-known/oauth-authorization-server, /authorize, /token,
// /register (o registro dinamico) e /revoke. Tudo isso o SDK entrega pronto —
// o que voce escreve e so o provider.
app.use(
mcpAuthRouter({
provider: ponte,
issuerUrl: new URL(URL_PUBLICA),
scopesSupported: ["notas"],
resourceName: "Exemplo MCP OAuth",
})
);
// Papel 3: a volta da API externa. Este endereco e o que precisa estar
// cadastrado la, letra por letra.
app.get("/callback", criarRotaDeCallback(ponte));
app.get("/saude", (_req, res) => res.json({ ok: true }));
// Papel 1: as ferramentas, atras do Bearer. O `verifier` e a propria ponte —
// e o verifyAccessToken dela que decide quem entra.
const exigirBearer = requireBearerAuth({
verifier: ponte,
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(URL_PUBLICA)),
});
O mcpAuthRouter é generoso: ele publica os metadados, o
/authorize, o /token, o /register e o
/revoke sozinho. Tudo o que você escreveu até aqui foi a classe que
ele chama por baixo.
E tem uma linha ali que parece decoração e não é:
app.set("trust proxy", 1);
Atrás de nginx, IIS ou de um túnel, sem isso o limitador de taxa que o
mcpAuthRouter usa reclama do X-Forwarded-For e
derruba o /register — bem no primeiro passo, com uma mensagem que
não menciona proxy nenhum. Ajuste o número para a quantidade de proxies à
frente.
✅ Rodando o fluxo inteiro sem o Claude
Depurar isso pelo Claude é sofrido: cada tentativa exige remover e adicionar o
conector, e você não vê as requisições. Então eu escrevi um script que faz
exatamente o que ele faria, do .well-known à chamada de ferramenta.
Foi ele que provou tudo que está neste artigo.
Esta é a saída real, sem cortes na ordem:
=== 1. Descoberta (.well-known) ===
{
"authorization_endpoint": "http://localhost:3011/authorize",
"token_endpoint": "http://localhost:3011/token",
"registration_endpoint": "http://localhost:3011/register",
"code_challenge_methods_supported": [ "S256" ]
}
=== 2. Registro dinamico de cliente (DCR) ===
client_id inventado pelo cliente: 042f2d7e-dbc2-4292-9b45-1e6bac0cba10
=== 3. /authorize -> redireciona para a API externa ===
302 -> http://localhost:3012/oauth/authorize?client_id=exemplo_mcp&redirect_uri=…&state=<nosso-id-de-sessao>
=== 4. O usuario faz login e clica em Autorizar ===
a API externa devolve -> http://localhost:3011/callback?code=<code-da-api-externa>&state=<nosso-id>
=== 5. Nosso /callback: troca o code por tokens e devolve ao cliente ===
302 -> http://localhost:9999/pronto?code=<codigo-NOSSO>&state=N9YlWVCtjBSOdb85gCWyXg
state devolvido confere com o enviado: true
=== 6. /token com o verifier do PKCE ===
token_type: bearer | expires_in: 86400 | scope: notas
access_token do MCP: wSe_6fUzk6KU…
=== 7. tools/list ===
- listar_notas (readOnlyHint: true)
- criar_nota (readOnlyHint: false)
=== 8. listar_notas ===
{
"Dados": [
{ "id": 1, "titulo": "Comprar cafe", "texto": "O bom, em graos." },
{ "id": 2, "titulo": "Ligar para a Ana", "texto": "Combinar o almoco de sabado." }
],
"Total": 2
}
=== 10. SEM o token: o servidor tem de recusar ===
HTTP 401 | WWW-Authenticate: Bearer error="invalid_token",
error_description="Missing Authorization header",
resource_metadata="http://localhost:3011/.well-known/oauth-protected-resource"
=== 11. Token inventado: idem ===
HTTP 401 | {"error":"invalid_token","error_description":"Token invalido. Conecte a conta novamente."}
Os passos 10 e 11 são os que eu mais gosto, e vou explicar por quê. Um
"funcionou" prova que o caminho feliz existe; um "falhou do jeito
previsto" prova que você entendeu o problema. Sem token e com token
inventado, o servidor responde 401 — e no primeiro caso ainda manda o
WWW-Authenticate apontando para os metadados, que é exatamente o
sinal que faz um cliente saber como se autenticar.
No repositório do artigo esse mesmo roteiro está numa página no navegador, com um botão por passo, para você ver cada requisição sem escrever nada.
🔒 O que eu mudaria antes de pôr em produção
O exemplo roda inteiro na sua máquina, e isso tem preço. Três pontos honestos sobre o que é simplificação didática:
O armazenamento é em memória. Todo mundo é desconectado a cada reinício do processo. Trocar por um banco é substituir um arquivo, e foi por isso que separei as funções desde o começo.
O client_secret tem um valor padrão no código. No
exemplo isso é conveniência; em produção é falha grave. Ele vive em variável de
ambiente, e nunca — nunca mesmo — em log, em repositório ou em qualquer coisa que
chegue ao navegador.
O Claude exige HTTPS público. Para conectar de verdade, é
túnel ou domínio, com o /callback cadastrado na API letra por letra.
Aquele mesmo redirect_uri do começo do artigo, que agora você já
sabe que é comparado como texto. 😉
📦 O código completo
Está tudo no repositório: os sete arquivos, a API de demonstração que se
comporta como as de verdade, e a página que percorre o fluxo passo a passo no
navegador. O README ensina a reproduzir de propósito as duas coisas mais
importantes: o refresh acontecendo, e o erro do redirect_uri.
Se você chegou até aqui querendo conectar o Claude a uma API que já existe: a parte difícil não é o MCP, e não são as ferramentas. É entender que o seu servidor precisa ser um Authorization Server para um lado e um app cadastrado para o outro, ao mesmo tempo, sem que nenhum dos dois descubra. Depois que essa ficha cai, o resto é o código que você acabou de ler.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Por que meu conector personalizado simplesmente não conecta?
/.well-known/oauth-authorization-server, se registra sozinho inventando um client_id na hora (Dynamic Client Registration), abre o /authorize com PKCE e troca o código no /token. A sua API não tem registro dinâmico e não vai passar a ter.Posso repassar o token da minha API direto para o Claude?
Por que a API responde HTTP 400 dizendo que o redirect_uri não confere?
redirect_uri é comparado como texto, não como endereço nem como host. Trocar localhost por 127.0.0.1 já derruba o fluxo, mesmo sendo a mesma máquina e a mesma porta, e uma barra a mais no fim faz o mesmo estrago. O erro acontece antes da tela de login, e a mensagem não ajuda. Por isso o endereço deve nascer de uma variável só, com todo o resto derivando dela.Por que o usuário vê "erro no servidor" em vez de poder reconectar?
Error comum vira HTTP 500. O InvalidGrantError é traduzido pelo SDK para 400, e o InvalidTokenError é o único tipo que o middleware traduz para 401 com o cabeçalho WWW-Authenticate, que é justamente o sinal que faz o Claude oferecer "reconectar". É um detalhe de três palavras no throw que decide se a experiência tem conserto.Onde tratar a renovação do token que venceu?
refresh_token normalmente rotaciona, então sem gravar funciona uma vez e falha na seguinte.A ferramenta deve receber um parâmetro dizendo de quem são os dados?
usuario seria um convite a ler a conta alheia: bastaria pedir ao Claude "liste as notas do fulano" e torcer. Se a sua API exige um identificador de conta no cabeçalho, ele tem de sair do que você guardou junto do token, nunca do que o modelo escreveu no argumento.Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.