Arquitetura do astro-webmcp: Manifest, Injection e Design Decisions
Visão geral da arquitetura
O astro-webmcp opera em três camadas:
- Build time — Gera o manifesto JSON estático com metadata de todas as páginas
- Runtime (browser) — Injeta script leve que registra tools via WebMCP
- Agent — Descobre e consome tools registradas
┌─────────────────────────────────────────────────────────┐
│ BUILD TIME │
│ │
│ Astro pages ────→ Hook astro:build:done │
│ │ │
│ ▼ │
│ /_webmcp/manifest.json │
│ (titles, slugs, descriptions) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ RUNTIME (Browser) │
│ │
│ Injected script (injectScript) ~1KB gzipped │
│ │ │
│ ├─ Feature detection: │
│ │ document.modelContext ?? navigator.modelContext│
│ │ │
│ ├─ fetch('/_webmcp/manifest.json') │
│ │ │
│ └─ mc.registerTool() │
│ ├── search_content │
│ ├── list_sections │
│ ├── go_to │
│ └── get_page_info │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ AI AGENT │
│ │
│ Chrome 149+ descobre tools via WebMCP protocol │
│ Agent pode buscar, listar e navegar conteúdo do site │
└─────────────────────────────────────────────────────────┘
Hooks Astro utilizados
A integração usa 3 hooks do Astro para operar em diferentes fases:
| Hook | Fase | Função |
|---|---|---|
astro:config:setup | Config | Injeta script client-side via injectScript('page', ...) |
astro:server:setup | Dev | Serve manifesto dinâmico durante astro dev |
astro:build:done | Build | Gera /_webmcp/manifest.json do HTML compilado |
astro:config:setup
Registra o script que será injetado em toda página do site:
// Simplificação do que o hook faz:
addIntegration({
name: 'astro-webmcp',
hooks: {
'astro:config:setup': ({ injectScript }) => {
injectScript('page', generateClientScript(options));
}
}
});
O script injetado é minificado e pesa ~1KB gzipped. Contém:
- Feature detection
- Fetch do manifesto
- Registro das 4 tools built-in
- Custom tools (se configuradas)
astro:server:setup
Durante desenvolvimento (astro dev), serve o manifesto dinamicamente:
'astro:server:setup': ({ server }) => {
server.middlewares.use('/_webmcp/manifest.json', (req, res) => {
// Gera manifesto on-the-fly para dev
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(buildManifest()));
});
}
Isso permite que as tools funcionem em dev sem rodar astro build. Detalhe que faz diferença no dia a dia.
astro:build:done
Após o build, percorre o HTML compilado para extrair metadata:
'astro:build:done': ({ pages, dir }) => {
const entries = [];
for (const page of pages) {
const html = readFileSync(join(dir, page.pathname, 'index.html'), 'utf-8');
const title = extractTitle(html); // <title>...</title>
const description = extractMeta(html); // <meta name="description">
const headings = extractHeadings(html); // h1, h2, h3
entries.push({
slug: page.pathname,
url: `/${page.pathname}/`,
title,
description,
collection: inferCollection(page.pathname),
tags: extractTags(html)
});
}
writeFileSync(
join(dir, '_webmcp', 'manifest.json'),
JSON.stringify({ generatedAt: new Date().toISOString(), entries })
);
}
O manifesto JSON
Estrutura
{
"generatedAt": "2026-06-16T14:30:00.000Z",
"site": "https://prompt.api.br",
"collections": [
{ "name": "blog", "count": 42 },
{ "name": "docs", "count": 15 },
{ "name": "webmcp", "count": 9 }
],
"entries": [
{
"slug": "webmcp/introducao",
"url": "/webmcp/introducao/",
"title": "WebMCP: O Protocolo que Torna Sites Legíveis para Agentes de IA",
"description": "Entenda o que é WebMCP, como funciona...",
"collection": "webmcp",
"tags": ["webmcp", "chrome", "ai-agents"]
}
]
}
Características
| Aspecto | Decisão | Motivo |
|---|---|---|
| Formato | JSON estático | CDN-cacheable, sem server runtime |
| Geração | Build time | Sem custo runtime, funciona SSG e SSR |
| Localização | /_webmcp/manifest.json | Convenção, same-origin fetch |
| Conteúdo | Apenas metadata pública | Equivalente a sitemap.xml |
O que o manifesto contém vs sitemap.xml
| Dado | Manifesto | sitemap.xml |
|---|---|---|
| URLs | ✅ | ✅ |
| Títulos | ✅ | ❌ |
| Descriptions | ✅ | ❌ |
| Collections | ✅ | ❌ |
| Tags | ✅ | ❌ |
| Lastmod | ❌ | ✅ |
| Priority | ❌ | ✅ |
O manifesto é um sitemap enriquecido — contém dados semânticos que o agente usa para busca e navegação inteligente.
Client script injection
O que é injetado
O script injetado em toda página segue esta lógica:
// Versão simplificada do script injetado
(async () => {
// 1. Feature detection
const mc = document.modelContext ?? navigator.modelContext;
if (!mc?.registerTool) return; // Sai silenciosamente
// 2. Fetch manifesto
const manifest = await fetch('/_webmcp/manifest.json').then(r => r.json());
// 3. Helpers
const truncate = (s, max) => s.length > max
? s.slice(0, max - 30) + '\n... [truncated]'
: s;
const sanitize = (s) => s.replace(/ignore\s+(previous|prior)\s+instructions/gi, '[FILTERED]');
// 4. Registrar tools
const options = { exposedTo: [] }; // Config de security
mc.registerTool({
name: 'search_content',
description: 'Search articles and pages by keyword',
inputSchema: { /* ... */ },
execute: async ({ query, collection, limit = 5 }) => {
const results = manifest.entries
.filter(e => {
const haystack = `${e.title} ${e.description || ''} ${(e.tags || []).join(' ')}`.toLowerCase();
const match = haystack.includes(query.toLowerCase());
return match && (!collection || e.collection === collection);
})
.slice(0, Math.min(limit, 20));
return truncate(sanitize(JSON.stringify(results)), 1500);
},
annotations: { readOnlyHint: true, untrustedContentHint: true }
}, options);
// ... list_sections, go_to, get_page_info
})();
Tamanho e performance
| Métrica | Valor |
|---|---|
| Tamanho do script (minificado) | ~1KB gzipped |
| Impacto no load time | Negligível (async, non-blocking) |
| Fetch do manifesto | Lazy (só quando tools são registradas) |
| CPU usage | Zero (sem polling, event-driven) |
O script é non-blocking: se o browser não suporta WebMCP, sai na primeira linha sem custo nenhum. Quem usa Firefox nem percebe que existe.
Design decisions
Por que manifesto JSON estático (não virtual module)?
| Alternativa | Problema |
|---|---|
| Vite virtual module | Não funciona com SSG puro; requer bundler runtime |
| API endpoint | Precisa de server runtime; não funciona em CDN |
| Inline no HTML | Aumenta tamanho de toda página; não cacheable |
| JSON estático ✅ | Funciona SSG e SSR, CDN-cacheable, sem Vite plugin |
Por que busca client-side (não server-side)?
| Alternativa | Problema |
|---|---|
Server endpoint /api/search | Requer SSR; não funciona em deploy estático |
| External search service | Dependência adicional; latência; custo |
| Client-side filter ✅ | Zero infra; funcional para sites <1000 páginas |
Para sites muito grandes (>1000 páginas), considere um search endpoint MCP em paralelo. Mas pra 99% dos sites Astro, client-side resolve.
Por que API Imperativa (não Declarativa)?
| Motivo | Explicação |
|---|---|
| Search não é form | Busca precisa de lógica JS (filter, slice, format) |
| Navigation não é form | go_to causa window.location.href change |
| Controle de output | Precisa truncar, sanitizar, formatar JSON |
| Lifecycle management | AbortController para cleanup |
A API Declarativa é complementar — use para formulários reais (contato, newsletter, etc.).
Por que feature detection com fallback?
const mc = document.modelContext ?? navigator.modelContext;
O Chrome 149 usava navigator.modelContext. O Chrome 150+ mudou para document.modelContext (breaking change). O fallback garante compatibilidade:
| Chrome | Interface | Fallback funciona? |
|---|---|---|
| 149 | navigator.modelContext | ✅ |
| 150+ | document.modelContext | ✅ |
| Sem suporte | Ambos undefined | ✅ (sai silenciosamente) |
Segurança por design
Princípios implementados
| Princípio | Implementação |
|---|---|
| Sem execução de código arbitrário | Tools só lêem dados estáticos ou navegam |
Sem innerHTML | Output é JSON.stringify — zero vetor XSS |
| Sem requests externos | Manifesto fetched de same-origin only |
| Progressive enhancement | Browser sem suporte: script sai imediatamente |
| Origin-isolated | Cross-origin iframes sem allow="tools" não acessam |
| Defense-in-depth | Múltiplas camadas independentes |
Superfície de ataque minimizada
O astro-webmcp não:
- Executa código dinâmico baseado em input do agente
- Faz requests para URLs fornecidas pelo agente
- Expõe dados além do que já está no HTML público
- Modifica o DOM baseado em output de tools
- Armazena dados de sessão do agente
Fluxo de dados detalhado
┌─────────────────────────────────────────────────────────────┐
│ 1. BUILD (astro build) │
│ │
│ src/content/blog/*.md ──┐ │
│ src/content/docs/*.md ──┼── Astro SSG ──→ dist/ │
│ src/pages/*.astro ──┘ │ │
│ │ │
│ astro:build:done hook ←───────────────────────┘ │
│ │ │
│ ▼ │
│ Percorre dist/**/*.html │
│ │ │
│ ▼ │
│ Extrai: <title>, <meta description>, <h1>-<h3>, pathname │
│ │ │
│ ▼ │
│ dist/_webmcp/manifest.json │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. DEPLOY │
│ │
│ dist/ → CDN/hosting (Vercel, Netlify, Cloudflare, etc.) │
│ manifest.json é apenas mais um arquivo estático │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. RUNTIME (visitante abre página) │
│ │
│ Browser carrega HTML ──→ Script injetado executa │
│ │ │
│ ├── mc = document.modelContext ?? navigator... │
│ │ (se null → return — custo zero) │
│ │ │
│ ├── fetch('/_webmcp/manifest.json') │
│ │ (cacheable — CDN serve; ~5-50KB) │
│ │ │
│ └── mc.registerTool() x4 (ou mais com customTools) │
│ │
│ Tools agora visíveis para agentes via WebMCP │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. AGENT (Chrome built-in ou extensão) │
│ │
│ Descobre tools → Escolhe tool → Envia args → Recebe output │
│ │
│ Exemplo: │
│ search_content({ query: "WebMCP" }) → JSON results │
│ list_sections() → collections com contagem │
│ go_to({ slug: "webmcp/seguranca" }) → navegação │
└─────────────────────────────────────────────────────────────┘
Extensibilidade
Adicionando tools sem modificar o plugin
Registre tools adicionais em qualquer página Astro — sem tocar na config do plugin:
---
// src/pages/products.astro
---
<html>
<body>
<!-- Conteúdo da página -->
<script>
// Tool específica desta página
const mc = document.modelContext ?? navigator.modelContext;
if (mc?.registerTool) {
mc.registerTool({
name: 'filter_products',
description: 'Filter products by category and price range',
inputSchema: {
type: 'object',
properties: {
category: { type: 'string', enum: ['electronics', 'books', 'clothing'] },
maxPrice: { type: 'number', description: 'Maximum price in BRL' }
}
},
execute: async ({ category, maxPrice }) => {
// Lógica de filtro
return JSON.stringify(filteredProducts);
}
});
}
</script>
</body>
</html>
Essas tools coexistem com as tools do astro-webmcp — o agente vê todas.
Integração com headless CMS
Se seu conteúdo vem de um CMS, o manifesto será gerado a partir do HTML compilado pelo Astro. Não importa a source:
- Markdown local
- Contentful
- Sanity
- Notion
- Qualquer outro CMS
O hook astro:build:done opera sobre o HTML final, independente de onde o conteúdo nasceu.
Limitações da arquitetura
| Limitação | Motivo | Workaround |
|---|---|---|
| Busca é full-scan | Client-side filter no manifesto | OK para <1000 páginas |
| Manifesto desatualizado | Estático (gerado no build) | Rebuild para atualizar |
| Sem full-text search | Manifesto contém apenas metadata | Adicione tool custom com search API |
| Sem auth-aware | Plugin é fully static | Registre tools auth-aware separadamente |
| Sem streaming | Outputs são JSON strings | Limitação da spec WebMCP (issue #82) |
Próximos passos
- astro-webmcp — Guia de uso e instalação
- astro-webmcp Config — Todas as opções de configuração
- API Imperativa — A API usada internamente pelo plugin
- Introdução ao WebMCP — Fundamentos do protocolo