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.
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/sdk1.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. 401comWWW-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
403einsufficient_scopepara que os clientes possam aumentar o nível. - Sem repasse de token: o token de portador é descartado após a verificação.
- Metadados de Recurso Protegido (RFC 9728) em
- 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,
/healthze 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.jsondo 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.
| Script | O que faz |
|---|---|
npm run dev | Executa com tsx watch, carregando .env se presente |
npm run build | Compila para dist/ |
npm start | Executa o servidor compilado |
npm run typecheck | tsc --noEmit |
npm run lint | Verificação Biome (lint, formatação, ordem de importação) |
npm run format | Aplica correções Biome |
npm test | Vitest |
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.
- Registre este servidor como uma API (um "recurso"). Use
PUBLIC_URLcomo seu identificador, por exemplohttps://mcp.example.com. Os provedores o chamam de API, servidor de recursos ou público. - Defina os escopos
notes:readenotes:write, além de quaisquer outros seus. - Faça os tokens de acesso carregarem este servidor em
aud. O cliente MCP envia o parâmetroresourcedo 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, definaAUTH_AUDIENCEpara o valor que ele usa. - 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.
- Configure este servidor. Copie o valor de
issuerdehttps://<your-idp>/.well-known/openid-configurationparaAUTH_ISSUER, caractere por caractere (alguns provedores o terminam com uma barra, e a comparação é exata). DeixeAUTH_JWKS_URLvazio para descobrir o conjunto de chaves do emissor, ou defina-o para pular a descoberta. - Verifique. Obtenha um token para seu usuário e chame
whoamiatravés de qualquer cliente MCP. Ele retorna osube 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ável | Obrigatória | Padrão | Significado |
|---|---|---|---|
PUBLIC_URL | sim | Origem canônica deste servidor (esquema, host, porta opcional; sem caminho). Publicada como o resource dos metadados. | |
AUTH_ISSUER | sim | Emissor do seu servidor de autorização. https obrigatório, exceto localhost. | |
AUTH_JWKS_URL | não | descoberto | Endpoint JWKS. Vazio significa descobri-lo dos metadados do emissor (descoberta OIDC, depois RFC 8414). |
AUTH_AUDIENCE | não | PUBLIC_URL | aud esperado dos tokens de acesso. |
ALLOWED_ORIGINS | não | nenhum | Origens de navegador separadas por vírgula permitidas a chamar o servidor. Vazio recusa toda solicitação com um cabeçalho Origin. |
HOST | não | 127.0.0.1 | Interface para vincular. A imagem Docker define 0.0.0.0. |
PORT | não | 3000 | Porta para escutar. |
Configuração inválida interrompe o processo na inicialização com uma mensagem que nomeia cada variável incorreta.
Endpoints
| Método | Caminho | Autenticação | Propósito |
|---|---|---|---|
POST | /mcp | Token de portador | O endpoint MCP (HTTP Streamable, respostas JSON) |
GET, DELETE | /mcp | Token de portador | 405: o modo sem estado não tem sessões nem fluxo iniciado pelo servidor |
GET | /.well-known/oauth-protected-resource | nenhum | Metadados de Recurso Protegido (RFC 9728) |
GET | /healthz | nenhum | Vivacidade: {"status":"ok"} |
Como o servidor responde, e por quê:
| Situação | Status | WWW-Authenticate |
|---|---|---|
| Sem credenciais | 401 | Bearer scope="notes:read", resource_metadata="..." |
| Assinatura inválida, emissor ou público errados, expirado, malformado | 401 | Bearer error="invalid_token", ..., resource_metadata="..." |
| Token válido, ferramenta precisa de um escopo que falta | 403 | Bearer error="insufficient_scope", scope="notes:write", resource_metadata="...", error_description="..." |
Cabeçalho Origin não está na lista de permissões | 403 | nenhum |
| Provedor de identidade inacessível (não é possível buscar chaves) | 500 | nenhum, 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.
NotesStorerecebe 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
InMemoryNotesStorepor 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
josee 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'sSignJWT, então um teste pode definir qualquersub,scope,aud,issouexp, ou assinar com uma chave que o JWKS não publica; test/helpers/harness.tsexecuta o carregador de configuração real, inicia o aplicativo real na porta0e conecta com o próprioClienteStreamableHTTPClientTransportdo 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.
- Copie-o para
server.jsone substitua os espaços reservados:name,title,description(no máximo 100 caracteres),version, as URLs do repositório e oremotes[0].urldo seu endpoint/mcpimplantado. - O
namedeve 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. - 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.
auddeve 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.
nonee 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).
expesubsão obrigatórios. - https para endpoints de identidade. O emissor, a URL do JWKS e o
jwks_uridescoberto devem usar https, exceto para localhost. - Buscas de chaves são limitadas. As chaves são armazenadas em cache, e um
kiddesconhecido 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
Originfora da lista de permissões recebe403. Com uma lista de permissões vazia, toda solicitação de navegador é recusada. Clientes que não são navegadores não enviamOrigine não são afetados. O servidor vincula-se a127.0.0.1a menos que seja instruído de outra forma. - Isolamento de locatário. O armazenamento é chaveado pelo
subdo 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