Anatomia do mcp.nfe.io: stateless, 3 transportes, schemas validados.
Um MCP server não precisa ser complicado. O nosso tem zero estado em memória, usa o SDK MCP oficial da Anthropic, e entrega a mesma superfície de tools por três transportes distintos — escolhe o que fizer sentido pro seu ambiente. Código aberto, MIT, auditável linha por linha.
Uma instância MCP por request. Morre no fim. Multi-tenant por natureza.
Todo request HTTP cria um McpServer fresco, instancia um NfeClient com a API key extraída do header, registra as 5 tools, processa a mensagem JSON-RPC, responde e descarta. Zero state entre requests — o servidor é um pass-through puro.
export default { async fetch(request: Request): Promise<Response> { // 1. Extrai Bearer token do Authorization header const apiKey = extractApiKey(request); if (!apiKey) return unauthorized(...); // 2. Cria client NFE.io pra esse request só const nfe = createNfeClient({ apiKey, lookupApiKey }); // 3. Instancia McpServer fresco e registra as 5 tools const server = new McpServer({ name, version }); registerNfeTools(server, nfe); // 4. Conecta transport, processa, descarta const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless enableJsonResponse: true, }); await server.connect(transport); return transport.handleRequest(request); } };
Por que stateless
Toda superfície de ataque vinha de "dados armazenados" — sessão, cache de key, pool de conexão com estado. Sem isso, a classe de bug cross-tenant leak deixa de existir por construção. Dois clientes diferentes batendo no mesmo momento nunca podem tocar no estado um do outro porque não há estado.
Efeito colateral bom: horizontal scaling sai de graça. Cada request pode ir pra qualquer instância, qualquer região, qualquer runtime.
API key viaja no header
Autenticação é Bearer puro: Authorization: Bearer <NFE_API_KEY>. Opcionalmente X-NFE-Lookup-Key pra chave separada de consulta. Ambas vivem ~150ms em memória de request e são descartadas. Nunca logadas, nunca cacheadas.
Trade-off consciente: cliente precisa mandar a key a cada request. Em troca: zero responsabilidade do servidor sobre vida/expiração da key.
Mesmas 5 tools. Três formas de entregar.
A lógica core vive em src/register-tools.ts. Cada entry point é um adapter fino: stdio pra rodar via npx, Streamable HTTP pro Cloudflare Workers, Express HTTP pro container OCI. Mudar de um pro outro não exige refatorar tool.
npx @nfe/mcp-server
O cliente MCP spawna o processo. Comunicação via stdin/stdout. Ideal pra dev local, ambiente corporativo com firewall, uso em pipeline de CI.
- Entry: src/index.ts
- Auth: env var NFE_API_KEY
- Latência: zero overhead (inter-process)
- Sem HTTP, sem TLS
mcp.nfe.io
Cloudflare Workers na borda global. Cliente MCP conecta direto com "type": "http". Sem instalação, sem Node local. SLA 99.99%, cold start quase zero.
- Entry: src/worker.ts
- Auth: header Bearer por request
- Latência: ~50ms p50 overhead
- Multi-tenant natural
ghcr.io/nfe/mcp-server
Imagem OCI multi-arch (amd64 + arm64). Roda em AWS Fargate, GCP Cloud Run, Azure Container Apps, Kubernetes, Fly.io, on-prem. Mesma superfície HTTP.
- Entry: src/http-server.ts (Express)
- Auth: header Bearer por request
- Non-root user · HEALTHCHECK
- Attested build provenance
Cada tool é um trio: schema, descrição, handler.
O SDK MCP oficial da Anthropic aceita registrar tools com schema Zod, descrição longa e annotations. Anotamos todas as 5 corretamente — read-only e idempotentes pra consultas, destrutivas pra emissão. O LLM lê as annotations no handshake e ajusta comportamento sem configuração extra.
nfeio_lookup_cnpj
nfeio_ evita colisão com outros MCP servers instalados no mesmo cliente.z.object({ cnpj, updateAddress }).strict() — Zod com .strict(): qualquer campo extra é rejeitado antes de tocar na API.readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: true — o Claude Desktop usa isso pra decidir se pergunta confirmação.async (nfe, params) => { ... } — recebe o NfeClient injetado, chama UMA operação do SDK, retorna via jsonResult(). Try/catch delega erros a handleNfeError().O agente precisa de erros acionáveis, não stack traces.
O SDK nfe-io tem hierarquia de erros bem definida. src/errors.ts mapeia cada classe pra uma mensagem MCP que o agente entende e consegue agir em cima — sem vazar detalhe interno, sem ambiguidade.
if (error instanceof AuthenticationError) { return formatError( "NFE.io authentication failed. Verify that NFE_API_KEY " + "is valid and has not been revoked." ); } if (error instanceof ValidationError) { return formatError( `NFE.io rejected the request due to invalid data: ${error.message}` ); } if (error instanceof PollingTimeoutError) { return formatError( `Invoice is still processing after polling timeout. Some municipalities take 3-5 min. Use nfeio_get_invoice_status with the returned invoiceId to check again later.` ); } // ...8 outras classes tratadas com mensagens específicas
O que esperar em produção.
Números medidos em staging — o overhead do servidor MCP é marginal; o gargalo real é a latência de saída pra api.nfe.io e, de lá, pra cada prefeitura.
| Métrica | Valor | Contexto |
|---|---|---|
| CPU time por request | < 10ms | Tempo de execução JS ativa no Worker (sem contar I/O wait). |
| Overhead MCP · p50 | ~50ms | Da chegada do request na borda até resposta, descontando a chamada pra API NFE.io. |
| Overhead MCP · p99 | ~150ms | Cauda longa absorvida por variação de rede e retry transiente. |
| Cold start | ≈ 0ms | V8 isolates — Workers sobem o código em 5-10ms. Sem container pra aquecer. |
| SLA (Cloudflare Workers) | 99,99% | Quatro noves. Em 2 anos de histórico público, a plataforma já entrega acima disso. |
| Bundle size | 183 KB gzip | 999 KB raw — bem dentro do limite de 10 MB do Workers. |
| Requests simultâneos | ilimitado | Stateless + edge = cada request vai pro isolate mais próximo, sem contenção. |
Não é caixa preta. Clona, lê, modifica, forka.
Código em github.com/nfe/mcp-server, licença MIT, Dockerfile incluído, Cloudflare Workers config incluído. Você pode rodar a sua própria instância. A nossa em mcp.nfe.io é só a oficial, não é a única possível.
Build provenance
Cada release vem com attestation assinada pelo GitHub Actions via Sigstore. A imagem Docker em ghcr.io também. Verificável com gh attestation verify — você prova, sem confiar, que o binário veio daquele commit específico.
CI completo em cada PR
Matrix Node 18/20/22, typecheck em duas configs (stdio + worker), build, npm pack --dry-run, smoke test que sobe o servidor e valida as 5 tools, wrangler deploy --dry-run, build Docker + boot test. Se o PR merge, algo foi realmente testado.
Release com gate humano
npm publish só roda depois que um reviewer aprova o GitHub Environment production-publish. Elimina release acidental de tag mal-criada. Pre-releases (v0.2.0-rc.1) vão automaticamente pro dist-tag next, não latest.
Tipagem end-to-end
TypeScript 5.7 + strict mode + noUncheckedIndexedAccess. Zero any no código. Inputs Zod com .strict(), descriminated unions pros retornos do SDK. Se você contribuir com um PR, o tsc te protege de 80% dos regressões.
Lê o código. Forca, melhora, manda PR. Usa em produção.
É um MCP server pequeno, bem estruturado, open source. Ideal pra quem quer entender como um bom MCP server é construído — ou pra quem quer self-hostar uma versão própria.