CodeGym /Cursos /ChatGPT Apps /Configurando o MCP Server como recurso protegido:

Configurando o MCP Server como recurso protegido: .well-known, Bearer, audience/scope

ChatGPT Apps
Nível 10 , Lição 3
Disponível

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:

  1. 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.
  2. 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
resource
Identificador HTTPS/HTTP canônico do servidor MCP. Deve coincidir com o aud do token.
authorization_servers
Lista de URLs dos seus servidores de autorização (Auth Server/issuer). O cliente irá até lá buscar os metadados OAuth/OIDC.
scopes_supported
Lista de scopes suportados; ajuda o cliente a construir um bom UX e solicitar o token corretamente.
bearer_methods_supported
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:

  1. Chamam .well-known/oauth-protected-resource.
  2. Com base em authorization_servers, encontram o Auth Server e seus metadados OpenID/OAuth.
  3. 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:

  1. extrair Authorization do cabeçalho;
  2. na ausência/erro, retornar 401 por meio da nossa unauthorized;
  3. 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:

  1. Validar localmente a assinatura e os claims do JWT, usando as chaves JWK do Auth Server.
  2. 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.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION