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:

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.
Este artigo foi útil?