Pular para o conteúdo
nandomoreira.dev

Como eu construí meu novo site em 2026 que conversa com IA via MCP

#astro#mcp#ia

Ajustes de leitura
Fundo
Tamanho do texto
Entrelinha
Largura

Casa de ferreiro, espeto de pau. Eu trabalho com engenharia de IA e desenvolvimento de software, e o meu site não conversava com IA nenhuma. Era uma página que vendia “desenvolvedor web full-stack” para quem abrisse, e para um agente fazendo triagem era só HTML para raspar e adivinhar.

Comecei a reconstruir em agosto, fiz a base em dois dias e parei. Em outubro voltei, e dessa vez fui até o fim.

Hoje qualquer cliente MCP conecta nele:

claude mcp add --transport http nandomoreira https://nandomoreira.dev/api/mcp

A resposta que eu recebi quando rodei no meu terminal foi essa:

Added HTTP MCP server nandomoreira with URL: https://nandomoreira.dev/api/mcp to local config

Sem cerimônia. A partir daí, um agente que precisa saber o que eu já coloquei em produção chama list_projects e responde a partir de uma lista estruturada, e não de um palpite sobre o meu HTML.

Duas audiências, e uma delas não tem olhos

Antes de qualquer código escrevi quatro documentos: brief, PRD, escopo do MVP e guia de design. O brief cabe numa frase: um portfólio que é a própria demonstração do que ele afirma, escrito para humano e para máquina.

O leitor humano é o avaliador técnico, CTO ou tech lead, que quer ver artefato funcionando e decisão explicada. O outro é o agente que faz a triagem por ele, cada vez mais comum, e que quer extrair competência, disponibilidade e evidência sem alucinar.

O brief também listou os riscos, e o mais provável não era técnico: os cases nunca serem escritos e o site ir ao ar com placeholder (todo mundo que já refez portfólio conhece esse filme). A saída foi de processo: os oito cases entraram numa sprint antes da home.

Primeiro, provar que a plataforma aguenta

O site é Astro 7 em modo SSR, publicado no Firebase App Hosting. O adapter oficial da plataforma estava na versão 0.0.1 desde janeiro de 2025, versão única. Abandonado, na prática.

Como toda a parte de máquina depende de rota de servidor, a primeira sprint foi um spike de dois dias com critério de parada escrito antes de começar: ou o plano A sobe em produção, ou vou para o plano B, site estático com Cloud Functions. O plano A subiu no primeiro dia, com @astrojs/node em modo standalone. A decisão ficou num ADR, com as alternativas descartadas.

Uma fonte só, e um teste que garante isso

O conteúdo mora em duas coleções, projects e profile, validadas por schema. Delas saem a home, os cases, o llms.txt, o JSON-LD, o currículo e as tools do MCP.

Fonte única costuma durar até a primeira página com pressa ler direto da coleção. Aqui ela é um teste: se qualquer arquivo fora da camada de acesso chamar getCollection, a suíte fica vermelha.

for await (const file of glob('src/**/*.{ts,astro}')) {
  const normalized = file.replaceAll('\\', '/');
  if (ALLOWED.includes(normalized)) continue;

  if (readFileSync(file, 'utf8').includes('getCollection(')) {
    offenders.push(normalized);
  }
}

expect(offenders).toEqual([]);

Para este site isso pesa mais que o normal. Se a home disser uma coisa e o MCP outra, o agente responde errado com confiança, que é justamente o que o site existe para evitar.

O servidor MCP

A ideia de pôr um servidor MCP num portfólio não é minha. Veio do site do Ruben Marcus, que expõe o portfólio para agentes e conta como fez isso num artigo sobre construir o portfólio com agentes de IA.

O meu tem cinco tools:

Tool O que devolve
get_profile Quem sou, o que faço, onde estou
check_availability Disponibilidade e a data da última atualização
list_projects Os cases, com stack e links verificáveis
get_project Um case completo pelo slug
get_resume O currículo estruturado

A rota usa o SDK oficial com o transporte Streamable HTTP, que recebe um Request e devolve um Response, o mesmo formato de uma rota SSR do Astro. O detalhe que importa está no comentário:

/**
 * One server AND one transport per request.
 *
 * Hoisting either to module scope is the natural shape and the wrong one: in
 * stateless mode a shared instance collides request IDs between concurrent
 * clients, and this backend serves with concurrency 80.
 */
const handle = async (request: Request): Promise<Response> => {
  const server = await createMcpServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  await server.connect(transport);
  return closeWhenDrained(await transport.handleRequest(request), server);
};

Criar o servidor uma vez, no topo do módulo, é o jeito natural de escrever e o jeito errado aqui: com 80 requisições simultâneas numa instância, clientes diferentes trocam IDs de requisição entre si. Funciona no teste com um cliente e quebra em produção com dois.

Fechar o servidor também tinha armadilha. Num finally, a resposta saía vazia; sem fechar, sobrava um servidor por requisição. Quem achou foi o Codex, revisando o código que outro modelo escreveu. A correção foi fechar só quando o stream da resposta termina.

A página /mcp mostra como conectar no Claude Code, no Claude, no ChatGPT e em qualquer cliente MCP.

Toda página tem uma versão para máquina

Nem todo agente fala MCP. Para os que só leem, cada página tem a versão <página>.md, o markdown puro, e a <página>/ia, a página como um agente a enxergaria. Um botão no canto troca entre a visão humana e a visão IA.

Uma tabela só liga caminho a markdown, e página fora dela responde 404. Prefiro um 404 honesto a um markdown gerado no escuro.

Completam a parte de máquina o llms.txt, o AGENTS.md, o currículo em resume.json e resume.txt, e o JSON-LD. A lista do que um answer engine procura veio do aeo.js, também do Ruben. Não instalei a biblioteca: usei o que ela gera como referência e implementei cada rota no projeto, saindo da mesma camada de conteúdo das páginas. O verificador dele, o check.aeojs.org, foi o que usei para medir o resultado.

Um agente pode me contratar

O POST /api/hire recebe uma proposta em JSON, direto de um agente que terminou a triagem. O schema recusa chave desconhecida em vez de guardar em silêncio, e todo campo tem limite de tamanho. O mesmo endpoint atende o formulário de contato das pessoas.

E nenhum e-mail aparece nas rotas de máquina. Um teste varre todas procurando e-mail, chave, caminho absoluto e IP privado, porque a premissa é de crawler que ignora o robots.txt.

Sete portões

Regra escrita em documento não impede ninguém de quebrá-la numa sexta à noite. Então cada regra que importava virou teste:

Portão O que impede
Contraste Par de cor abaixo de 4,5:1
Valor fora da escala Cor solta e valor arbitrário do Tailwind
JavaScript de cliente Segundo script, ou o único acima de 1,5 KB gzip
Fonte única getCollection fora da camada de acesso
Schema quebra o build Conteúdo inválido chegando em produção
CTA único Dois botões de mesmo peso na home
Vazamento E-mail, chave, caminho absoluto, IP privado nas rotas de máquina

Um portão só está pronto depois de ter ficado vermelho. Para cada um escrevi o caso que deveria falhar e vi falhar.

O de JavaScript diz muito do site: existe um script de cliente, com cerca de 700 bytes gzip, que faz o efeito do ponteiro, o botão de copiar e o fundo do header ao rolar. Nenhum framework no navegador.

E tem a regra do número com contexto. Tenho 964 estrelas no GitHub, mas umas 700 vieram de temas Jekyll de 2018. Mostrar o total sem dizer isso seria vender trabalho com IA usando a prova social de outra época, então o componente que exibe número quebra se não receber a explicação junto.

O que deu errado

Alguns problemas só apareceram em produção:

  • O redirect que não redirecionava. Numa página pré-renderizada, o redirect 301 vira um documento com meta refresh servido com status 200. No navegador funciona; para crawler e cache, não é redirect. Os testes estavam verdes porque procuravam a string 301 no código.
  • O 403 atrás do proxy. O Astro confere a origem dos formulários e, atrás do proxy do App Hosting, achava que a requisição vinha por HTTP. Todo envio do formulário de contato voltava 403.
  • A mensagem que sumia. O formulário dizia sucesso e nada chegava ao Firestore: faltava uma permissão na conta de serviço, e a gravação falhava calada.
  • O gerenciador de senhas no honeypot. O campo escondido que pega robô se chamava website, e o gerenciador de senhas (provavelmente o 1Password) preenchia sozinho. Mensagem de gente de verdade ia para o lixo como spam.

Como foi construído

Com agentes, num fluxo de pesquisa, plano e implementação em que o Claude Code conduz, o Grok escreve e o Codex revisa. Contei como funciona em Fluxo RPI com três agentes. Em números: mais de 300 commits, 16 specs, 16 planos e mais de 1.400 testes.

Tendência ou demonstração

Eu acho que site com servidor MCP pode virar tendência entre devs e engenheiros de IA. Para mim, é demonstração: se eu digo que construo ferramenta para agentes, o meu próprio site precisa ser consumível por um.

Se você estiver avaliando alguém para um problema de IA, pode começar pelo agente:

claude mcp add --transport http nandomoreira https://nandomoreira.dev/api/mcp

Ou pelo caminho humano, na página de contato.