← Voltar ao Blog

Adicionando um servidor MCP a um OSS: do YAML à imagem PNG

Precisava de uma imagem de roteiro para mostrar a um cliente. Uma planilha colada numa apresentação não ia resolver — queria algo visual de verdade.

Tinha o historymap, um OSS que eu mesmo fiz. Você passa um YAML e ele gera um HTML de timeline com dez layouts diferentes. Tem suporte a iframe, auto-resize via postMessage, tudo embutido sem CDN. “Perfeito”, pensei. Aí começou a encrenca.

Primeiro: o gerador só sai HTML. Eu precisava de PNG.

Segundo: não dá para passar o caminho do arquivo por argumento. O código assume que data.yaml fica na raiz do repositório.

Terceiro, e mais frustrante: rodei node src/build.mjs --help e o build simplesmente aconteceu. Nenhuma mensagem de ajuda. Nenhum erro. Tentei --format png. Mesma coisa. Qualquer flag que eu inventasse, o script lia o data.yaml e gerava o HTML em silêncio. Meu próprio OSS, e eu precisava ler o código-fonte pra saber o que era possível fazer.

A solução mais limpa: servidor MCP

Se eu queria que um agente de IA gerasse a imagem, a forma mais direta era expor o historymap como ferramenta MCP. O Claude Code chamaria generate_timeline(yaml, layout: "skyline", format: "png") e receberia a imagem na hora.

O historymap já fazia YAML → HTML. Com Puppeteer tirando um screenshot, viraria PNG. Foram três arquivos novos:

src/screenshot.mjs    HTML → PNG (Puppeteer)
mcp/handlers.mjs      lógica das ferramentas (separada pra poder testar)
mcp/server.mjs        servidor MCP (@modelcontextprotocol/sdk)

Duas decisões que valeram a parada

1. yaml (string inline) ou yamlPath (caminho do arquivo)?

Comecei aceitando o YAML como string:

{ "tool": "generate_timeline", "yaml": "title: ...\nitems:\n  ..." }

Funcionou. Mas com arquivos maiores que 100 linhas, passar o YAML inteiro numa mensagem ficou pesado. Cada vez que eu pedia “ajusta esse campo e gera de novo”, o agente mandava o YAML completo de volta. Editar o arquivo e passar só o caminho deixou tudo muito mais ágil.

A solução foi aceitar os dois, de forma exclusiva:

generate_timeline({ yaml: "...", layout: "skyline", format: "png" })
generate_timeline({ yamlPath: "/caminho/data.yaml", format: "png" })

Aqui saiu um bug que me custou uns minutos: a validação de yaml precisa checar yaml === undefined, não !yaml. Com !yaml, a string vazia "" cai no mesmo erro de “nenhum dos dois fornecido” — e a mensagem que aparece não faz sentido nenhum.

2. Onde colocar o Puppeteer

Puppeteer baixa o Chrome junto, então incluir como dependência obrigatória deixa o npm install pesado pra todo mundo, mesmo quem só quer HTML.

Fui de optionalDependencies com import dinâmico:

let puppeteer;
try {
  puppeteer = (await import("puppeteer")).default;
} catch {
  throw new Error(
    "PNG export requires puppeteer, but it is not installed.\n" +
    "  Install:  npm install puppeteer\n" +
    "  If you used --omit=optional, re-run without those flags.\n" +
    "  Or set PUPPETEER_EXECUTABLE_PATH to an existing Chrome binary."
  );
}

Aqui tem uma armadilha silenciosa: em CI com npm install --production ou --omit=optional, o Puppeteer não é instalado. O HTML continua saindo normalmente, então você não percebe que o PNG parou de funcionar — até tentar gerar uma imagem. Por isso o erro menciona --omit=optional explicitamente.

Em ambientes onde o Chrome já existe, basta apontar pra ele:

PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable \
  node src/cli.mjs --format png --data ./roteiro.yaml

O CLI que precisava de reforma

Junto com o MCP, reescrevi o CLI com parseArgs. A situação antes era: só --all funcionava, o resto era descartado em silêncio.

# depois
node src/cli.mjs --data ./roteiro.yaml --layout skyline --format png --width 1400
node src/cli.mjs --help          # mostra o uso
node src/cli.mjs --flag-inventado  # erro + uso

Com strict: true no parseArgs, qualquer flag desconhecida vira erro. Um detalhe que me pegou: o --help precisa só de return sem alterar process.exitCode pra sair com exit 0. Na primeira tentativa esqueci disso, o --help devolvia exit 1 e o && na linha de comando parava.

Quando comecei a usar, três coisas quebraram

O MCP e o CLI estavam funcionando. Voltei a gerar o roteiro do cliente. Foi aí que veio o próximo problema.

1. O campo description sumia no layout skyline

Gerei a imagem com quatro marcos no skyline. Na hora de revisar antes de enviar, percebi que o campo description tinha sumido de todos os cartões.

- date: "2026-08-02"
  title: "Autenticação · iframe"
  subtitle: "Semanas de 02 e 10/08"
  description: "Autenticação, embed via iframe, validação em staging"
  # ↑ esse campo estava sendo ignorado

O renderizador só desenhava title e subtitle. Adicionei o description e, em vez de cortar com overflow: hidden, deixei extravasar pra fora da área delimitada do track. O skyline tem cara de slide — faz mais sentido o texto aparecer do que desaparecer sem aviso.

2. Dois marcos no mesmo mês duplicavam o rótulo de data

Com 2026-07-06 e 2026-07-19 no mesmo YAML, o rótulo 2026.07 aparecia duas vezes lado a lado.

2026.07   2026.07   2026.08   2026.08
  ↑         ↑
  duplicado

A correção é simples: comparar o displayLabel do item atual com o anterior e esconder quando são iguais. visibility: hidden no lugar de display: none mantém o espaço no layout.

3. Texto em japonês com símbolos ASCII quebrava em lugares estranhos

A ficava sozinha no começo de uma linha. A ) aparecia em linha própria. Notei isso revisando a imagem antes de enviar pro cliente — por pouco.

.skyline-content {
  line-break: strict;
  word-break: keep-all;
  overflow-wrap: break-word;
}

line-break: strict proíbe quebra antes de pontuação CJK. Os símbolos param de aparecer no começo de linha.

O resultado

Skyline com largura 1400px, description visível, rótulos sem duplicata:

Roteiro de desenvolvimento — layout skyline

Para usar via MCP no Claude Code, adicione ao .mcp.json:

{
  "mcpServers": {
    "historymap": {
      "command": "node",
      "args": ["/caminho/para/historymap/mcp/server.mjs"],
      "env": {
        "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/google-chrome-stable"
      }
    }
  }
}

Depois é só pedir: “gera o roteiro desse projeto no layout skyline em PNG”. O agente escreve o YAML, chama a ferramenta e devolve a imagem.

O que ficou claro

Adicionar MCP a um OSS que já existe é mais rápido do que construir do zero. O historymap já fazia YAML → HTML — o que veio depois foi só HTML → PNG e a definição das ferramentas.

Mas o que me pegou foi outra coisa: o CLI que ignorava argumento, o description que sumia, o rótulo duplicado, a quebra de linha estranha — nenhum desses apareceu enquanto eu desenvolvia. Apareceram quando tentei usar de verdade, num projeto real, com prazo.

O código está em github.com/kenimo49/historymap.


As mudanças descritas aqui estão no CHANGELOG.md.