1. MCP Server como Resource Server: o que exatamente estamos configurando
Na aula passada configuramos o Auth Server — o componente que emite tokens. Agora vamos cuidar do outro lado desse par: o servidor MCP como Resource Server, que recebe e valida esses tokens.
Do ponto de vista do OAuth 2.1, seu servidor MCP é um Resource Server. Ele armazena “recursos” (ferramentas do MCP, dados do usuário) e recebe requisições com um access token no cabeçalho Authorization: Bearer .... Antes de executar uma ferramenta (tool), ele deve verificar se o token é verdadeiro, não expirou, foi emitido por um servidor de autorização confiável (Auth Server) e se é destinado especificamente a este servidor MCP, além de possuir os direitos necessários (scope).
É importante separar dois níveis:
- Nível de transporte — aqui são processados os cabeçalhos HTTP e os tokens. Nele você:
- aceita/analisa Authorization: Bearer,
- na ausência/erro do token, retorna 401 Unauthorized com WWW-Authenticate: Bearer ...,
- com token válido, forma o contexto do usuário.
- Nível do MCP SDK, que não precisa saber nada sobre JWT. Ele apenas recebe uma chamada “já autenticada” e, dentro do handler, pode usar ctx.userId, ctx.scopes etc.
Uma analogia: o MCP SDK é o cozinheiro na cozinha, e o middleware OAuth é o segurança na porta. O cozinheiro não verifica passaportes; ele apenas prepara os pedidos.
Como exemplo didático, continuamos com o GiftGenius: servidor MCP em http://localhost:3000 com a ferramenta list_my_gifts, e um Auth Server (por exemplo, Keycloak ou um mini AS personalizado) em http://localhost:4000.
2. .well-known/oauth-protected-resource: o “cartão de visita” do seu recurso MCP
Para que serve o .well-known do recurso
Quando o ChatGPT (ou o MCP Jam) acessa seu servidor MCP pela primeira vez e recebe 401, ele precisa entender duas coisas:
- para onde ir buscar o token;
- quais permissões esse recurso suporta.
Para evitar “hardcode” nos clientes, usa-se um endpoint de discovery:
GET /.well-known/oauth-protected-resource
Esse endpoint retorna um JSON com os metadados do recurso protegido (Protected Resource Metadata) conforme a RFC 9728.
Exemplo do GiftGenius:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
A OpenAI, em seus guias, mostra praticamente o mesmo exemplo, só que com HTTPS e domínios reais.
O cliente (ChatGPT/Jam) lê esse documento e:
- entende que o token deve ter audience http://localhost:3000;
- entende com quais authorization_servers trabalhar (issuer URL);
- vê a lista de scopes suportados (facilita compor a tela de consentimento e os prompts).
Análise dos campos de metadados
Resumo dos principais campos:
| Campo | Finalidade |
|---|---|
|
Identificador HTTPS/HTTP canônico do servidor MCP. Deve coincidir com o aud do token. |
|
Lista de URLs dos seus servidores de autorização (Auth Server/issuer). O cliente irá até lá buscar os metadados OAuth/OIDC. |
|
Lista de scopes suportados; ajuda o cliente a construir um bom UX e solicitar o token corretamente. |
|
Formas de enviar o token: normalmente ["header"], ou seja, Authorization: Bearer .... |
Opcionalmente, às vezes publicam resource_documentation, jwks_uri, introspection_endpoint etc., mas para o cenário básico bastam os quatro primeiros.
Ponto crítico: resource deve coincidir com o que o Auth Server coloca em aud no token. Se não coincidir, o cliente MCP (e você) vai reclamar e rejeitar o token.
Implementação do .well-known no Next.js 16
Suponha que nosso servidor MCP rode em um app Next.js (Apps SDK backend, porta 3000). A forma mais simples é criar um route handler em app/.well-known/oauth-protected-resource/route.ts:
// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const body = {
resource: "http://localhost:3000",
authorization_servers: ["http://localhost:4000"],
scopes_supported: ["gifts:read", "gifts:write"],
bearer_methods_supported: ["header"],
};
return NextResponse.json(body);
}
Em produção, resource deve ser o URL HTTPS do ambiente de produção do seu servidor MCP (por exemplo, https://mcp.giftgenius.com) e deve coincidir com o aud nos tokens do IdP.
3. WWW-Authenticate e 401: como o MCP indica “é preciso um token”
Já criamos o “cartão de visita” do recurso em .well-known/oauth-protected-resource. Agora vamos ver como o servidor MCP indica ao cliente que é preciso consultá-lo — via 401 e o cabeçalho WWW-Authenticate.
Cenário básico: requisição sem token
Imagine que o ChatGPT chama a ferramenta list_my_gifts pela primeira vez. A requisição de rede pode ser algo assim:
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
Não há token. O servidor MCP não deve retornar silenciosamente 403 nem alguma página HTML qualquer. O comportamento correto de um recurso protegido no mundo OAuth é responder com 401 Unauthorized e, por meio do cabeçalho WWW-Authenticate, explicar ao cliente como se autorizar.
Exemplo de resposta correta:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json
{"error":"unauthorized","error_description":"Missing or invalid access token"}
Detalhes importantes:
- o esquema Bearer informa que queremos um token OAuth Bearer;
- o parâmetro resource_metadata aponta para a URL de .well-known/oauth-protected-resource;
- o parâmetro scope indica qual é o escopo mínimo necessário (por exemplo, gifts:read).
MCP Jam e ChatGPT conseguem ler esse cabeçalho. Ao vê-lo, eles:
- Chamam .well-known/oauth-protected-resource.
- Com base em authorization_servers, encontram o Auth Server e seus metadados OpenID/OAuth.
- Disparam o fluxo Authorization Code + PKCE, abrem a página de login para o usuário e obtêm o token.
Ou seja, WWW-Authenticate é o gatilho: sem ele, o cliente nem imagina que há OAuth ali.
Middleware para respostas 401 (Next.js)
Vamos escrever um pequeno utilitário para usar em todos os endpoints protegidos. Primeiro, a função que forma a resposta:
// lib/authResponses.ts
import { NextResponse } from "next/server";
export function unauthorized(scope?: string) {
const wwwAuth = [
`Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
scope ? `scope="${scope}"` : null,
]
.filter(Boolean)
.join(", ");
return new NextResponse(
JSON.stringify({
error: "unauthorized",
error_description: "Missing or invalid access token",
}),
{
status: 401,
headers: {
"WWW-Authenticate": wwwAuth,
"Content-Type": "application/json",
},
}
);
}
Agora qualquer rota (por exemplo, nosso endpoint MCP) pode simplesmente chamar return unauthorized("gifts:read"), e o cliente receberá o challenge correto. A função unauthorized() retorna um objeto NextResponse (compatível com o Response padrão). Nos exemplos seguintes, às vezes vamos lançar esse objeto como exceção e, nos route handlers, capturar especificamente um Response para não duplicar o código de formação da resposta 401 em cada rota.
4. Recepção e validação do token Bearer
Agora, a parte mais interessante: como receber e validar o token Bearer.
Onde fazer a validação
O transporte MCP provavelmente está implementado de uma das formas:
- em um route handler do Next.js (app/mcp/route.ts), que recebe um POST e delega para o MCP SDK;
- em um servidor Express/Fastify, que escuta /mcp e passa o JSON para o handler do MCP.
Em todos esses cenários, é a camada HTTP que deve:
- extrair Authorization do cabeçalho;
- na ausência/erro, retornar 401 por meio da nossa unauthorized;
- em caso de sucesso, formar o objeto de contexto (userId, scopes, roles) e passá-lo ao MCP SDK (via argumentos do handler/contexto).
O MCP SDK em si (por exemplo, @modelcontextprotocol/sdk) pode nem saber o que é JWT. Essa responsabilidade é sua.
Formas de validação: JWT vs introspection
Existem dois estilos principais:
- Validar localmente a assinatura e os claims do JWT, usando as chaves JWK do Auth Server.
- Consultar /introspect no servidor de autorização e perguntar: “Esse token ainda está válido? Quais são seus scopes?”
Neste curso vamos assumir que o Auth Server emite JWT e publica jwks_uri, e que o servidor MCP valida assinatura e claims localmente (é mais rápido e autônomo).
Utilitário verifyAccessToken em TypeScript
Vamos usar a biblioteca popular jose (amigável a ESM). Precisamos de um helper aproximadamente assim:
// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: EXPECTED_ISS,
audience: EXPECTED_AUD,
});
return {
sub: String(payload.sub),
scopes: String(payload.scope || "").split(" ").filter(Boolean),
raw: payload,
};
}
Neste helper nós:
- baixamos as chaves JWK do Auth Server via jwks_uri;
- validamos a assinatura e os claims padrão (iss, aud);
- extraímos sub (id do usuário) e scope (string separada por espaço, por isso fazemos split(" ")).
audience deve coincidir com resource de nosso .well-known/oauth-protected-resource, garantindo que o token foi emitido para este servidor MCP.
Checagem simples do cabeçalho Authorization
Agora vamos criar um pequeno helper que extraia o token do cabeçalho e o passe por verifyAccessToken:
// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";
export async function getUserFromRequest(req: NextRequest) {
const auth = req.headers.get("authorization") || "";
const [, token] = auth.split(" ");
if (!token) throw unauthorized("gifts:read");
try {
return await verifyAccessToken(token);
} catch {
throw unauthorized("gifts:read");
}
}
Observe que aqui lançamos unauthorized(...) (isto é, um objeto Response) como exceção, para que o route handler possa capturá-lo de forma concisa e retorná-lo como resposta.
5. audience e scope: vinculação do token ao recurso e às ações
Audience (aud): “para quem” o token foi emitido
O claim aud responde à pergunta: este token é destinado a este recurso? No nosso caso:
- aud no token o Auth Server define como http://localhost:3000;
- nosso .well-known/oauth-protected-resource publica resource: "http://localhost:3000";
- verifyAccessToken verifica que isso é verdade.
Se o token for destinado a outro recurso (por exemplo, https://api.other-app.com), seu servidor MCP deve rejeitá-lo como “não endereçado a mim”.
Erro típico — esquecer de sincronizar resource e aud, fazendo com que, apesar de tudo parecer certo, o ChatGPT receba constantemente 401. Voltaremos a isso no bloco “Erros comuns”.
Scopes: “o que exatamente” pode ser feito
O claim scope no token lista as permissões que o usuário concedeu ao cliente. No nosso exemplo:
- gifts:read — permissão para ler seus presentes;
- gifts:write — permissão para criar/atualizar presentes.
Em .well-known/oauth-protected-resource, esses valores aparecem como scopes_supported, para que o cliente saiba de antemão o que pode solicitar.
O servidor de autorização, no seu documento de discovery (.well-known/openid-configuration), também publica scopes_supported, mas esse é o conjunto global de scopes do IdP (não confundir com o .well-known/oauth-protected-resource do resource server).
É importante não confundir essas duas listas: scopes_supported do recurso descreve quais permissões são necessárias especificamente ao seu servidor MCP, enquanto scopes_supported do IdP é o “catálogo” global de scopes do provedor. O cliente normalmente usa a interseção desses conjuntos.
No nível do servidor MCP, você precisa:
- definir, para cada ferramenta, quais scopes são exigidos;
- verificar, a cada chamada, que o token contém esses scopes.
Vamos escrever um helper:
// lib/requireScope.ts
import { unauthorized } from "./authResponses";
export function requireScope(
user: { scopes: string[] },
needed: string[]
) {
const hasAll = needed.every((s) => user.scopes.includes(s));
if (!hasAll) throw unauthorized(needed.join(" "));
}
Agora é possível chamar requireScope(user, ["gifts:read"]) antes de executar a ferramenta.
6. Integração com as ferramentas MCP: do token ao list_my_gifts
Rota MCP no Next.js
Suponha que temos um servidor MCP baseado em algum SDK que sabe processar requisições HTTP. No Next.js, isso pode ser assim:
// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
const body = await req.json();
const result = await mcpServer.handle(body, { user });
return Response.json(result);
} catch (err) {
if (err instanceof Response) return err; // unauthorized(...)
console.error(err);
return unauthorized();
}
}
É importante que:
- extraiamos o usuário e os scopes do token (getUserFromRequest);
- passemos isso ao servidor MCP via contexto { user };
- na ausência/erro do token, retornemos nosso 401 com WWW-Authenticate.
A API exata do MCP SDK pode variar, mas a ideia é sempre a mesma: envolver a chamada do MCP com um middleware que já sabe “quem” está chamando.
Ferramenta list_my_gifts com verificação de scope
Agora vejamos a implementação da própria ferramenta. Suponha que usamos o TypeScript SDK para MCP, e temos algo como:
// lib/mcpServer.ts (trecho)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";
export const mcpServer = createMcpServer<{ user: any }>();
mcpServer.registerTool(
"list_my_gifts",
{
title: "List my gifts",
description: "Shows your saved gift ideas.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
async (_input, ctx) => {
requireScope(ctx.user, ["gifts:read"]);
const gifts = await loadGiftsForUser(ctx.user.sub);
return {
content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
structuredContent: { gifts },
};
}
);
Fazemos três passos-chave:
- exigimos gifts:read antes de executar o código principal;
- usamos ctx.user.sub como identificador do usuário (do token);
- retornamos dados apenas desse usuário.
Assim, sua ferramenta deixa de ser uma “API geral” e torna-se personalizada — vinculada à identidade do Auth Server.
7. Resumo do fluxo: do 401 à chamada bem-sucedida
Para fixar tudo, vamos montar um mini-esquema do fluxo que o seu servidor MCP protegido agora implementa.
sequenceDiagram
participant ChatGPT
participant MCP as MCP Server (3000)
participant AS as Auth Server (4000)
ChatGPT->>MCP: POST /api/mcp (no Authorization)
MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...
ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }
ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
AS-->>ChatGPT: redirect with ?code=XYZ
ChatGPT->>AS: POST /token (code + code_verifier)
AS-->>ChatGPT: { access_token, scope, ... }
ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
MCP->>MCP: verify JWT (iss, aud, exp, scope)
MCP-->>ChatGPT: tool result for this user
Observe o parâmetro resource nas requisições ao Auth Server: ele é copiado para aud no token e deve coincidir com resource em .well-known/oauth-protected-resource.
8. Um pequeno teste prático com curl
Para conferir na prática, podemos fazer duas requisições manualmente.
Primeira — tentativa de chamar o MCP sem token:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
Esperamos ver o status 401 e nosso WWW-Authenticate com resource_metadata e scope="gifts:read".
Segundo — com um token válido (obtido do Auth Server):
curl -i http://localhost:3000/api/mcp \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
Agora, se abc123 for um JWT válido com iss correto, aud="http://localhost:3000" e scope incluir gifts:read, você receberá a resposta JSON da ferramenta, e em structuredContent.gifts estarão os presentes do usuário atual.
9. Erros comuns ao configurar o MCP Server como recurso protegido
A seguir está um conjunto de armadilhas que aparecem com mais frequência justamente na implementação do código que acabamos de escrever: .well-known, WWW-Authenticate, verificação do token e checagem de scopes.
Erro nº 1: resource e audience não sincronizados.
Muitas vezes, em .well-known/oauth-protected-resource é publicado um valor para resource, enquanto o Auth Server emite outro aud nos tokens. Como resultado, jwtVerify descarta o token, mesmo com assinatura e validade corretas. É especialmente fácil quebrar isso ao trocar o domínio/porta do servidor MCP e esquecer de atualizar ou o .well-known, ou a configuração do Auth Server. No nosso exemplo, é a mesma string http://localhost:3000 no campo resource de .well-known e em EXPECTED_AUD dentro de verifyAccessToken. Vale a pena criar uma única constante RESOURCE_ID e usá-la em ambos os lugares para evitar divergências.
Erro nº 2: ausência de WWW-Authenticate em respostas 401.
Às vezes os desenvolvedores apenas retornam 401 ou 403 sem o cabeçalho WWW-Authenticate. Para o navegador, pode até ser aceitável, mas o ChatGPT e o MCP Jam não saberão para onde ir buscar o token nem quais scopes são exigidos. Vão considerar seu servidor MCP “com problema” e nem exibir o UI de vinculação ao usuário. O mínimo necessário: WWW-Authenticate: Bearer com resource_metadata=".../.well-known/oauth-protected-resource". Melhor já incluir também scope="...", para deixar o fluxo mais claro. Nosso helper unauthorized() garante que, ao retornar 401, esse cabeçalho esteja sempre presente.
Erro nº 3: confiar no token sem validar assinatura e iss.
Às vezes, especialmente no início, a tentação é grande: “É um token do meu Auth Server; vou só fazer JSON.parse(atob(..)) e pronto”. Não faça isso: assim você aceitaria qualquer token com o formato “certo”, até um falsificado. A abordagem correta é carregar as chaves via jwks_uri e validar assinatura e iss/aud com uma biblioteca (jose, jsonwebtoken etc.). Só depois disso dá para confiar no conteúdo dos claims.
Erro nº 4: misturar validação do token com lógica de negócio.
Às vezes a validação do token fica espalhada pelo código das ferramentas: uma verifica scope, outra não; em algum lugar esquecem de checar aud; em outro, aceitam o id do usuário vindo como argumento da ferramenta. Isso leva a bugs estranhos e possíveis vulnerabilidades. Mantenha uma separação clara: o middleware na camada HTTP cuida do token (assinatura, iss, aud, expiração), e, na ferramenta, você se apoia em ctx.user como “a verdade”, acrescentando apenas verificações de negócio (por exemplo, papel/tenant).
Erro nº 5: divergência entre scopes_supported e os scopes realmente usados.
Outro caso comum: em .well-known/oauth-protected-resource você publica um conjunto de scopes, no Auth Server — outro, e nas ferramentas verifica um terceiro. ChatGPT/MCP Jam formam o pedido de autorização com base nos scopes_supported publicados, e seu servidor depois reclama que não há o scope necessário. Procure minimizar a quantidade de scopes e tratá-los como “fonte única da verdade” — por exemplo, via um enum em TypeScript usado tanto na geração do .well-known quanto na configuração de clientes no Auth Server.
Erro nº 6: depender apenas de securitySchemes do Apps SDK e esquecer a validação no servidor.
O Apps SDK permite descrever securitySchemes para ferramentas (noauth, oauth2, scopes), e o ChatGPT exibirá o UX apropriado ao usuário. Mas essas anotações não tornam o servidor automaticamente seguro. Mesmo que a ferramenta exija token OAuth, seu servidor MCP ainda precisa validar token, issuer, audience e scopes em cada requisição. Caso contrário, alguém pode contornar as checagens enviando a requisição diretamente para a URL do MCP.
Erro nº 7: esquecer o curto tempo de vida dos tokens e o tratamento de expiração.
Se os access tokens vivem tempo demais, a segurança cai; se vivem pouco, mas o servidor não trata expiração corretamente, o usuário topará com erros o tempo todo. O modelo correto é access token de vida curta e preparo do servidor MCP para retornar 401 com WWW-Authenticate quando o exp já estiver no passado. O cliente (ChatGPT) então repetirá o fluxo OAuth e atualizará o token.
GO TO FULL VERSION