remote-mcp-server-template

Modelo de servidor MCP remoto pronto para produção em TypeScript com OAuth 2.1, escopos por ferramenta e autorização progressiva

Documentação

remote-mcp-server-template

Um ponto de partida pronto para produção de um servidor MCP remoto em TypeScript: transporte HTTP Streamable, autorização de servidor de recursos OAuth 2.1, sem estado para escalar horizontalmente.

CI License: MIT

Clone-o, aponte-o para seu provedor de identidade, substitua os exemplos de ferramentas pelos seus. Qualquer cliente MCP que suporte o fluxo de autorização pode se conectar.

O que você obtém

  • Transporte HTTP Streamable no Express 5 com @modelcontextprotocol/sdk 1.32, em modo sem estado (sessionIdGenerator: undefined). Cada POST é autocontido, então qualquer réplica pode respondê-lo.
  • Servidor de recursos OAuth 2.1, seguindo a especificação atual de autorização MCP:
    • Metadados de Recurso Protegido (RFC 9728) em /.well-known/oauth-protected-resource.
    • 401 com WWW-Authenticate: Bearer resource_metadata="...".
    • Validação JWT contra o JWKS do emissor com jose: assinatura, emissor, público = este servidor (RFC 8707), expiração.
    • Escopos por ferramenta, respondidos com 403 e insufficient_scope para que os clientes possam aumentar o nível.
    • Sem repasse de token: o token de portador é descartado após a verificação.
  • Validação de origem contra rebinding de DNS, com uma lista de permissões do ambiente (e CORS para clientes de navegador na lista de permissões).
  • Três ferramentas de exemplo: whoami, notes_list (notes:read), notes_create (notes:write, entrada validada com zod), apoiadas por um armazenamento em memória isolado por usuário (sub).
  • Configuração de ambiente validada com zod, /healthz e logs JSON que nunca contêm um token.
  • Uma suíte Vitest que inicia o aplicativo real em uma porta efêmera e forja tokens localmente. Sem rede, sem provedor de identidade real.
  • TypeScript estrito, Biome, Dockerfile multi-estágio (não-root), CI, Dependabot e um template server.json do Registro MCP.

Início rápido

Requer Node.js 22 ou mais recente.

git clone https://github.com/romulorgc/remote-mcp-server-template.git
cd remote-mcp-server-template
npm install
cp .env.example .env     # set AUTH_ISSUER to your identity provider
npm run dev

O servidor escuta em 127.0.0.1:3000. Sem um token, ele informa aos clientes onde autenticar:

$ curl -i -X POST http://localhost:3000/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer scope="notes:read", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"

$ curl http://localhost:3000/.well-known/oauth-protected-resource
{"resource":"http://localhost:3000","authorization_servers":["https://your-tenant.example.com/"],"scopes_supported":["notes:read","notes:write"],"bearer_methods_supported":["header"],"resource_name":"Remote MCP server template"}

Aponte um cliente MCP para http://localhost:3000/mcp (use o mesmo host de PUBLIC_URL: os clientes verificam se o resource dos metadados corresponde à URL à qual se conectam). Para testar a partir de um cliente baseado em navegador, adicione a origem desse cliente a ALLOWED_ORIGINS.

ScriptO que faz
npm run devExecuta com tsx watch, carregando .env se presente
npm run buildCompila para dist/
npm startExecuta o servidor compilado
npm run typechecktsc --noEmit
npm run lintVerificação Biome (lint, formatação, ordem de importação)
npm run formatAplica correções Biome
npm testVitest

Conecte seu provedor de identidade

Este servidor nunca emite tokens. Ele confia em um servidor de autorização que você já executa ou aluga: Auth0, Keycloak, Clerk, WorkOS ou qualquer outro provedor OpenID Connect ou OAuth 2.0 que emita tokens de acesso JWT. Os passos são os mesmos em todos os lugares, apenas os nomes mudam.

  1. Registre este servidor como uma API (um "recurso"). Use PUBLIC_URL como seu identificador, por exemplo https://mcp.example.com. Os provedores o chamam de API, servidor de recursos ou público.
  2. Defina os escopos notes:read e notes:write, além de quaisquer outros seus.
  3. Faça os tokens de acesso carregarem este servidor em aud. O cliente MCP envia o parâmetro resource do RFC 8707; o provedor deve colocar esse valor, ou um público fixo que você configure para a API, no token. Se seu provedor não puder usar a URL como público, defina AUTH_AUDIENCE para o valor que ele usa.
  4. Permita que clientes MCP se registrem. A especificação pede que servidores de autorização suportem Documentos de Metadados de ID de Cliente OAuth, com Registro Dinâmico de Cliente como fallback. Ative o que seu provedor oferecer, ou pré-registre os clientes que você importa. Os clientes usam código de autorização com PKCE.
  5. Configure este servidor. Copie o valor de issuer de https://<your-idp>/.well-known/openid-configuration para AUTH_ISSUER, caractere por caractere (alguns provedores o terminam com uma barra, e a comparação é exata). Deixe AUTH_JWKS_URL vazio para descobrir o conjunto de chaves do emissor, ou defina-o para pular a descoberta.
  6. Verifique. Obtenha um token para seu usuário e chame whoami através de qualquer cliente MCP. Ele retorna o sub e os escopos que o servidor leu do token.

Os escopos são lidos da declaração scope (RFC 9068, separados por espaço) ou scp (string ou array). Se seu provedor colocar permissões em outro lugar, altere readScopes em src/auth/verifier.ts.

Configuração

VariávelObrigatóriaPadrãoSignificado
PUBLIC_URLsimOrigem canônica deste servidor (esquema, host, porta opcional; sem caminho). Publicada como o resource dos metadados.
AUTH_ISSUERsimEmissor do seu servidor de autorização. https obrigatório, exceto localhost.
AUTH_JWKS_URLnãodescobertoEndpoint JWKS. Vazio significa descobri-lo dos metadados do emissor (descoberta OIDC, depois RFC 8414).
AUTH_AUDIENCEnãoPUBLIC_URLaud esperado dos tokens de acesso.
ALLOWED_ORIGINSnãonenhumOrigens de navegador separadas por vírgula permitidas a chamar o servidor. Vazio recusa toda solicitação com um cabeçalho Origin.
HOSTnão127.0.0.1Interface para vincular. A imagem Docker define 0.0.0.0.
PORTnão3000Porta para escutar.

Configuração inválida interrompe o processo na inicialização com uma mensagem que nomeia cada variável incorreta.

Endpoints

MétodoCaminhoAutenticaçãoPropósito
POST/mcpToken de portadorO endpoint MCP (HTTP Streamable, respostas JSON)
GET, DELETE/mcpToken de portador405: o modo sem estado não tem sessões nem fluxo iniciado pelo servidor
GET/.well-known/oauth-protected-resourcenenhumMetadados de Recurso Protegido (RFC 9728)
GET/healthznenhumVivacidade: {"status":"ok"}

Como o servidor responde, e por quê:

SituaçãoStatusWWW-Authenticate
Sem credenciais401Bearer scope="notes:read", resource_metadata="..."
Assinatura inválida, emissor ou público errados, expirado, malformado401Bearer error="invalid_token", ..., resource_metadata="..."
Token válido, ferramenta precisa de um escopo que falta403Bearer error="insufficient_scope", scope="notes:write", resource_metadata="...", error_description="..."
Cabeçalho Origin não está na lista de permissões403nenhum
Provedor de identidade inacessível (não é possível buscar chaves)500nenhum, para que os clientes não entrem em loop de reautenticação

Um token válido é suficiente para conectar, chamar initialize e tools/list e usar ferramentas que não precisam de escopo. Uma chamada de ferramenta que precisa de mais é recusada na camada HTTP, antes que qualquer código de ferramenta execute. O parâmetro scope lista tudo o que essa chamada precisa, em um único desafio, para que um cliente possa reautorizar com a união de seus escopos antigos e novos e tentar novamente uma vez. Um lote JSON-RPC conta como uma operação. A verificação de escopo vive em src/auth/scope-guard.ts e lê as mesmas definições de ferramenta que registram as ferramentas, então os dois não podem divergir. Os manipuladores de ferramenta verificam novamente como defesa em profundidade.

A hierarquia de escopos é declarada em src/auth/scopes.ts: o escopo guarda-chuva notes implica notes:read e notes:write. Esvazie o mapa se seus escopos forem planos.

Adicione uma ferramenta

Crie um arquivo sob src/mcp/tools/:

// src/mcp/tools/notes-search.ts
import { z } from "zod";
import { SCOPES } from "../../auth/scopes.js";
import { defineTool, jsonResult } from "./define-tool.js";

export const notesSearch = defineTool({
  name: "notes_search",
  title: "Search notes",
  description: "Finds the caller's notes whose title or body contains the query.",
  scopes: [SCOPES.notesRead],
  inputSchema: {
    query: z.string().trim().min(1).max(200).describe("Text to look for, case-insensitive"),
  },
  outputSchema: {
    notes: z.array(
      z.object({ id: z.string(), title: z.string(), body: z.string(), createdAt: z.string() }),
    ),
  },
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
  async handler({ query }, { principal, notes }) {
    const needle = query.toLowerCase();
    const own = await notes.list(principal.sub, 100);
    const matches = own.filter(
      (note) =>
        note.title.toLowerCase().includes(needle) || note.body.toLowerCase().includes(needle),
    );
    return jsonResult({ notes: matches });
  },
});

Registre-o em src/mcp/tools/index.ts:

export const tools: readonly ToolDefinition[] = [whoami, notesList, notesCreate, notesSearch];

Isso é tudo. A ferramenta aparece em tools/list, seus scopes são aplicados pelo guarda HTTP, args é tipado a partir de inputSchema e o manipulador recebe principal (quem está chamando, do token) e o armazenamento. Precisa de um novo escopo? Adicione-o a SCOPES em src/auth/scopes.ts; ele é anunciado nos metadados automaticamente.

Regras práticas:

  • Pegue o usuário de principal.sub, nunca dos argumentos da ferramenta.
  • Escope seu armazenamento por proprietário. NotesStore recebe o proprietário em cada chamada, então o isolamento faz parte da interface.
  • Não encaminhe o token do chamador para outro serviço. Se uma ferramenta precisar chamar um, use credenciais próprias ou uma troca de token OAuth.
  • Substitua InMemoryNotesStore por um banco de dados antes de executar mais de uma réplica.

Testes

npm test

Os testes não precisam de rede nem de provedor de identidade. test/helpers/idp.ts faz o papel do provedor:

  • gera um par de chaves ES256 com jose e publica a metade pública como um JWKS, além de um documento de descoberta OIDC, em uma porta efêmera;
  • tokens são forjados com jose's SignJWT, então um teste pode definir qualquer sub, scope, aud, iss ou exp, ou assinar com uma chave que o JWKS não publica;
  • test/helpers/harness.ts executa o carregador de configuração real, inicia o aplicativo real na porta 0 e conecta com o próprio Client e StreamableHTTPClientTransport do SDK.

Coberto: o desafio 401, o documento de metadados (também seguido pelo cliente SDK), inicialização e tools/list, público errado, emissor errado, tokens expirados e não assinados, chaves de assinatura estrangeiras, alg: none, escopo insuficiente (incluindo lotes, a hierarquia de escopos e a declaração scp), isolamento entre dois usuários, política de origem e preflight, descoberta e cache JWKS, validação de configuração e que os logs nunca contêm um token.

Implante com Docker

docker build -t remote-mcp-server .

docker run --rm -p 3000:3000 \
  -e PUBLIC_URL=https://mcp.example.com \
  -e AUTH_ISSUER=https://your-tenant.example.com/ \
  remote-mcp-server

A imagem é multi-estágio em node:24-alpine, instala dependências de produção do lockfile, executa como o usuário não privilegiado node e tem uma verificação de saúde em /healthz.

Coloque um proxy reverso ou balanceador de carga na frente para encerrar TLS. Sirva-o sobre https em produção e defina PUBLIC_URL para o endereço que os clientes realmente usam. Encaminhe o cabeçalho Authorization inalterado. Como o servidor é sem estado, você pode executar quantas réplicas precisar, sem sessões fixas, uma vez que o armazenamento de notas seja compartilhado.

Publique no Registro MCP

server.example.json é um template para o Registro MCP oficial, escrito contra o esquema 2025-12-11 server.json. Ele descreve um servidor remoto, então não há pacote para publicar primeiro.

  1. Copie-o para server.json e substitua os espaços reservados: name, title, description (no máximo 100 caracteres), version, as URLs do repositório e o remotes[0].url do seu endpoint /mcp implantado.
  2. O name deve estar em um namespace que você possa provar que possui: io.github.<your-user>/<name> com login do GitHub, ou um nome DNS reverso para um domínio que você controla.
  3. Instale mcp-publisher (veja o início rápido do registro), então:
mcp-publisher validate
mcp-publisher login github
mcp-publisher publish

Publique depois que o servidor estiver implantado e acessível: os clientes que o encontrarem no registro se conectarão a essa URL e iniciarão o fluxo de autorização descrito acima.

Notas de segurança

O que o template garante, e onde cada garantia vive:

  • Os tokens são vinculados a este servidor. aud deve corresponder, portanto tokens emitidos para outras APIs e tokens de ID são recusados (src/auth/verifier.ts).
  • Sem repasse de tokens. O token bearer bruto é descartado após a verificação. As ferramentas veem um Principal (sub, ID do cliente, escopos), nunca uma credencial que possam encaminhar.
  • Apenas algoritmos assimétricos. RS, PS, ES e EdDSA são aceitos. none e algoritmos HMAC não são, e as chaves vêm somente do JWKS do emissor.
  • Correspondência exata do emissor. Os documentos de descoberta devem nomear o emissor para o qual foram obtidos (RFC 8414, seção 3.3). exp e sub são obrigatórios.
  • https para endpoints de identidade. O emissor, a URL do JWKS e o jwks_uri descoberto devem usar https, exceto para localhost.
  • Buscas de chaves são limitadas. As chaves são armazenadas em cache, e um kid desconhecido aciona no máximo uma nova busca a cada 30 segundos, para que tokens forjados não inundem seu provedor de identidade.
  • Os escopos são aplicados duas vezes, na camada HTTP e no wrapper do manipulador, a partir de uma única declaração por ferramenta.
  • Validação de origem em todas as rotas. Uma solicitação com um cabeçalho Origin fora da lista de permissões recebe 403. Com uma lista de permissões vazia, toda solicitação de navegador é recusada. Clientes que não são navegadores não enviam Origin e não são afetados. O servidor vincula-se a 127.0.0.1 a menos que seja instruído de outra forma.
  • Isolamento de locatário. O armazenamento é chaveado pelo sub do token, e a API de armazenamento não pode ser chamada sem um proprietário.
  • Logs discretos. Uma linha JSON por solicitação: método, caminho, status, duração, assunto. Sem cabeçalhos, sem string de consulta, sem corpo, e um filtro de redação para nomes de chaves sensíveis como rede de segurança.
  • Entradas limitadas. Corpos de solicitação de 1 MB, limites de comprimento na entrada das ferramentas, um teto por usuário para notas armazenadas.
  • Sem superfície de sessão. Não há sessões para fixar ou sequestrar, e não há streams de longa duração.

O que deliberadamente fica por sua conta:

  • Limitação de taxa e controles de abuso. Faça isso no seu gateway ou proxy.
  • Revogação. Os tokens são validados como JWTs, não inspecionados. Use tempos de vida curtos.
  • Persistência. O armazenamento de notas está em memória e é por processo.
  • TLS. Encerre-o na frente do servidor.
  • O servidor de autorização. Este é apenas um servidor de recursos. Ele publica onde autenticar e verifica o que retorna.

Versões de protocolo

O servidor é construído sobre @modelcontextprotocol/sdk 1.32, que implementa revisões de protocolo até 2025-11-25 e o handshake initialize. A revisão mais recente da especificação (2026-07-28) remove sessões em nível de protocolo e esse handshake. Uma solicitação que declara a versão mais recente recebe um 400 listando as versões que este servidor suporta, que é o que permite que os clientes façam fallback. O comportamento de autorização aqui (metadados de recursos, desafios, vinculação de público, desafios de escopo) segue a especificação de autorização atual e não depende dessa escolha.

Estrutura do projeto

src/
  index.ts            entry point: config, listen, graceful shutdown
  app.ts              Express app: middleware order, routes, stateless MCP handler
  config.ts           environment schema (zod)
  logger.ts           JSON logger with redaction
  auth/
    challenge.ts      401 for requests without credentials
    verifier.ts       JWT access token verification (jose)
    jwks.ts           JWKS resolution and issuer discovery
    scopes.ts         scopes, hierarchy, WWW-Authenticate builder
    scope-guard.ts    per-tool scope enforcement (403 insufficient_scope)
    principal.ts      the authenticated caller, as tools see it
  http/
    origin.ts         Origin allowlist and CORS
    metadata.ts       Protected Resource Metadata (RFC 9728)
    jsonrpc.ts        JSON-RPC error responses
  mcp/
    server.ts         builds an McpServer with every tool registered
    tools/            tool definitions: whoami, notes_list, notes_create
  notes/store.ts      per-user notes store (in memory)
test/                 Vitest suites and the test identity provider

Licença

MIT © 2026 Rômulo Carvalho