- Docs/sistema-documentale/: master architettura multi-wiki - Docs/docugen-mcp/: design + implementation plan MCP server Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
DocuGen MCP — Design Document
MCP server standalone per generazione di documenti Markdown a partire da un template (Markdown + frontmatter + prompt), contenuto grezzo e variabili. LLM via OpenRouter. Deploy Docker su VPS esterna, accessibile da qualsiasi host MCP (Claude Code, Claude Desktop, altri client conformi).
Indice
- Panoramica
- Architettura generale
- Componenti interni
- Modello dati
- API MCP — tool esposti
- Dataflow
document_generate - Gestione errori
- Strategia di test
- Deploy e operatività
- Stack tecnologico
1. Panoramica
Scopo
docugen-mcp è un server MCP che espone strumenti per creare documenti Markdown professionali (fatture, lettere, preventivi, report) a partire da:
- un template persistito sul server (nome + frontmatter YAML + body Markdown con prompt e placeholder)
- un contenuto grezzo Markdown fornito dal client (es. "# Ordine ACME\n- 10 unità X...")
- un dict di variabili strutturate (es.
{cliente: "ACME", data: "2026-04-21", foto_prodotto: {kind: image, data_b64, mime}}) - istruzioni libere opzionali per tweak stilistici ("tono formale", "in inglese")
Il server assembla un prompt, chiama un modello LLM via OpenRouter e restituisce il Markdown finale al client.
Principi
- MCP protocol-first — il server è un MCP server, non una API REST con wrapper MCP. FastMCP è il framework principale.
- Standalone — repo dedicato, nessuna dipendenza da DocuGen. DocuGen e altri sistemi lo usano come client.
- Stateless sui documenti generati — il server non tiene archivio dei MD prodotti (solo log/cost tracking in SQLite). Il client conserva l'output.
- Persistenza solo dove serve — template sul filesystem (ispezionabili a mano), asset efimeri con TTL, metrica generazioni in SQLite.
- Validazione strict — se il template dichiara
required_variablese mancano, errore duro, niente chiamata LLM sprecata. - Errori espliciti — codici stringa ben definiti, retry solo su errori transitori upstream.
Cosa NON fa
- Non genera PDF né HTML (solo Markdown). Conversione fuori scope.
- Non tiene un archivio ricercabile dei documenti prodotti.
- Non fa OCR, estrazione metadati da documenti, ricerca semantica (ruolo di DocuGen).
- Non espone gestione utenti: una sola API key condivisa.
2. Architettura generale
Approccio: FastMCP principale + FastAPI sub-app
Un singolo processo ASGI, un container Docker, un volume disco.
┌──────────────────────────────────────────────┐
│ Container docugen-mcp (VPS esterna) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ FastMCP (ASGI root) │ │
│ │ Streamable HTTP su /mcp │ │
│ │ │ │
│ │ Tool esposti: │ │
│ │ • template_create │ │
│ │ • template_update │ │
│ │ • template_delete │ │
│ │ • template_list │ │
│ │ • template_get │ │
│ │ • document_generate │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ mount FastAPI sub-app │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ FastAPI sub-app │ │
│ │ GET /assets/{template}/{file} │ │
│ │ GET /generated/{gen_id}/{file} │ │
│ │ GET /health │ │
│ └────────────────────────────────────────┘ │
│ │
│ Moduli (librerie Python): │
│ • auth — middleware API key │
│ • template_store — filesystem │
│ • llm_client — OpenRouter (httpx) │
│ • renderer — prompt assembly │
│ • generation_store — SQLite + fs │
│ │
│ Volume Docker montato su /data: │
│ ├── templates/<nome>/ │
│ │ ├── template.md │
│ │ └── assets/* │
│ ├── generated/<gen_id>/ │
│ │ └── <var_name>.<ext> (TTL 30gg) │
│ └── docugen_mcp.db │
└──────────────────────────────────────────────┘
▲ │
│ HTTP(S) + API key │ HTTPS
│ ▼
┌─────┴──────────┐ ┌──────────┐
│ Host MCP │ │OpenRouter│
│ (Claude Code, │ │ │
│ Claude Desktop│ └──────────┘
│ altri) │
└────────────────┘
Motivazioni architetturali
- MCP è il prodotto. FastMCP guida la struttura. FastAPI compare solo per ciò che l'MCP protocol non copre: file statici per asset immagini e health check.
- Un processo unico mantiene deploy banale: un container, un comando, un volume.
- TLS fuori container. Reverse proxy esterno (Nginx/Traefik/Caddy) gestisce certificati e termina HTTPS. Il container espone HTTP su porta interna.
- Storage semplice. Template sul filesystem (leggibili a mano, backup = copia cartella, git-friendly se l'operatore vuole). Metriche e indice TTL in SQLite per query strutturate. Nessun DB esterno.
Scalabilità
Processo singolo basta per l'uso previsto (pochi client, decine di generazioni/minuto al massimo). Per picchi futuri: replicare il container dietro load balancer, spostare il volume su storage condiviso (NFS) o estrarre SQLite in Postgres. Scelte rimandate a quando il bisogno sarà concreto.
3. Componenti interni
auth — middleware API Key
- Legge header
Authorization: Bearer <key>da ogni richiesta (MCP e FastAPI). - Confronta con env var
API_KEY(singola chiave condivisa). - Respinge 401 se assente/errata.
- Esenzione:
/health.
template_store — persistenza filesystem
- Path base:
{DATA_DIR}/templates/<nome>/. - File
template.md= frontmatter YAML + body Markdown. - Directory
assets/contiene risorse statiche referenziate dal template. - Frontmatter standard:
---
name: fattura-commerciale
description: Fattura commerciale in italiano
model: anthropic/claude-sonnet-4 # opzionale, override default
required_variables:
- name: cliente
type: string
- name: data
type: string
- name: foto_prodotto
type: image # trigger per shape {kind, data_b64, mime}
instructions_hint: "tono formale, struttura blocchi"
---
- Body: Markdown con prompt di sistema e placeholder
{{cliente}},{{foto_prodotto}},./assets/logo.png. - API Python (async):
create(name, frontmatter, body, assets=None),get(name),update(name, ...),delete(name),list(). - Nome template: slug
^[a-z0-9][a-z0-9-]*$. Rifiuta path traversal, chars speciali. - Validazione frontmatter via Pydantic
TemplateFrontmatter.
llm_client — wrapper OpenRouter
- Unica responsabilità: mandare una richiesta OpenRouter e ritornare risposta + metrica utilizzo.
- httpx async. Retry con backoff esponenziale (max 3 tentativi) su timeout/5xx/429.
- Modello scelto da
frontmatter.model or env.LLM_MODEL_DEFAULT. - Ritorna
LLMResponse(text: str, model: str, tokens_in: int, tokens_out: int, cost_usd: float, latency_ms: int).
renderer — orchestratore della generazione
Riceve (template_name, content_md, variables, instructions) e ritorna il Markdown finale.
Passi:
template_store.get(template_name)→(frontmatter, body, assets_dir).- Validazione variabili (strict): ogni
required_variablesdev'essere presente con il tipo dichiarato. Fallimento → eccezione tipizzata senza chiamare LLM. - Generazione
gen_id = uuid4(). - Per ogni variabile di tipo
image: decodifica base64, valida mime, salva in{DATA_DIR}/generated/{gen_id}/{var_name}.{ext}, registra inephemeral_assetsconexpires_at = now + ASSET_TTL_DAYS, sostituisce la variabile con URL assoluto{PUBLIC_BASE_URL}/generated/{gen_id}/{var_name}.{ext}. - Rewrite asset paths del template:
./assets/<file>→{PUBLIC_BASE_URL}/assets/{template_name}/{file}. - Assembla prompt finale:
- system: body del template con placeholder sostituiti.
- user: contenuto grezzo + variabili formattate +
instructionsopzionali.
- Chiama
llm_client.chat(...)con il modello selezionato. - Registra la generazione in
generation_store(success o error). - Ritorna
GenerationResult(generation_id, markdown, model, tokens, cost_usd, ephemeral_assets_urls, ephemeral_expires_at).
generation_store — persistenza generazioni
- SQLite via aiosqlite. Due tabelle:
generations(id TEXT PK, timestamp INTEGER, template_name TEXT, model TEXT, tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL, success INTEGER, error_msg TEXT)ephemeral_assets(generation_id TEXT, var_name TEXT, file_path TEXT, mime TEXT, created_at INTEGER, expires_at INTEGER, PRIMARY KEY (generation_id, var_name))
- Cleanup job: task asyncio lanciato al bootstrap che ogni 24h cancella file e record con
expires_at < now().
mcp_server — entry point FastMCP
- Definisce i 6 tool MCP con decoratori
@mcp.tool(). - Ogni tool fa validazione input (Pydantic), invoca il modulo responsabile, serializza la risposta.
- Monta la sub-app FastAPI sul path root.
config — Pydantic Settings
Variabili d'ambiente (tutte con default dove sensato):
| Variabile | Default | Descrizione |
|---|---|---|
API_KEY |
(obbligatoria) | Chiave di autenticazione client MCP |
OPENROUTER_API_KEY |
(obbligatoria) | Chiave OpenRouter |
OPENROUTER_BASE_URL |
https://openrouter.ai/api/v1 |
Endpoint OpenRouter |
LLM_MODEL_DEFAULT |
anthropic/claude-sonnet-4 |
Modello usato se il template non specifica override |
PUBLIC_BASE_URL |
(obbligatoria) | URL pubblico del server, usato per costruire link asset |
DATA_DIR |
/data |
Path volume persistenza |
ASSET_TTL_DAYS |
30 |
TTL asset efimeri generati |
MAX_IMAGE_SIZE_MB |
10 |
Limite dimensione immagine base64 in variables |
LLM_TIMEOUT_SECONDS |
60 |
Timeout singola richiesta OpenRouter |
Struttura sorgente
src/docugen_mcp/
├── __init__.py
├── main.py # bootstrap FastMCP + FastAPI mount
├── config.py
├── auth.py
├── mcp_tools.py # definizione tool MCP
├── http_routes.py # static asset + health (FastAPI)
├── template_store.py
├── llm_client.py
├── renderer.py
├── generation_store.py
└── models.py # Pydantic models condivisi
tests/
├── unit/
├── integration/
└── e2e/
pyproject.toml
Dockerfile
docker-compose.yml
.env.example
README.md
4. Modello dati
Filesystem
{DATA_DIR}/
├── templates/
│ └── <nome-template>/
│ ├── template.md # frontmatter + body
│ └── assets/
│ └── logo.png # risorse statiche del template
├── generated/
│ └── <gen_id>/ # uuid4, uno per generazione
│ └── <var_name>.<ext> # immagini inline dal client (TTL)
└── docugen_mcp.db # SQLite
SQLite
generations
| Campo | Tipo | Note |
|---|---|---|
id |
TEXT PK | uuid4 della generazione |
timestamp |
INTEGER | unix epoch ms |
template_name |
TEXT | |
model |
TEXT | modello effettivamente usato |
tokens_in |
INTEGER | da OpenRouter usage |
tokens_out |
INTEGER | da OpenRouter usage |
cost_usd |
REAL | calcolato da OpenRouter |
success |
INTEGER | 0/1 |
error_msg |
TEXT NULL | codice errore se success=0 |
Indice su timestamp per query temporali sui costi.
ephemeral_assets
| Campo | Tipo | Note |
|---|---|---|
generation_id |
TEXT | FK logica a generations.id |
var_name |
TEXT | nome variabile (es. foto_prodotto) |
file_path |
TEXT | path assoluto sul filesystem |
mime |
TEXT | image/png, image/jpeg, image/webp |
created_at |
INTEGER | unix epoch ms |
expires_at |
INTEGER | unix epoch ms |
PRIMARY KEY (generation_id, var_name). Indice su expires_at per cleanup rapido.
5. API MCP — tool esposti
template_create
Input:
{
"name": "fattura-commerciale",
"frontmatter": {
"name": "fattura-commerciale",
"description": "...",
"model": "anthropic/claude-sonnet-4",
"required_variables": [
{"name": "cliente", "type": "string"},
{"name": "data", "type": "string"}
],
"instructions_hint": "..."
},
"body": "# Template Markdown con {{cliente}} placeholder...",
"assets": [
{"filename": "logo.png", "data_b64": "...", "mime": "image/png"}
]
}
Output: {"name": "fattura-commerciale", "created_at": "..."} o errore.
template_update
Stesso input di template_create, richiede nome esistente. Sovrascrive template.md; assets (se forniti) sostituiscono interamente la directory (nessun merge).
template_delete
Input: {"name": "fattura-commerciale"}. Rimuove directory template.
template_list
Input: vuoto. Output: lista di {name, description, updated_at}.
template_get
Input: {"name": "fattura-commerciale"}. Output: frontmatter + body + elenco asset (nomi, mime, size).
document_generate
Input:
{
"template_name": "fattura-commerciale",
"content_md": "# Ordine ACME\n- 10 unità X",
"variables": {
"cliente": "ACME S.r.l.",
"data": "2026-04-21",
"foto_prodotto": {
"kind": "image",
"data_b64": "iVBOR...",
"mime": "image/png"
}
},
"instructions": "tono molto formale"
}
Output:
{
"generation_id": "<uuid>",
"markdown": "...output MD...",
"model": "anthropic/claude-sonnet-4",
"tokens": {"in": 1234, "out": 2345},
"cost_usd": 0.0123,
"ephemeral_assets_urls": [
"https://mcp.vps/generated/<uuid>/foto_prodotto.png"
],
"ephemeral_expires_at": "2026-05-21T10:33:00Z"
}
6. Dataflow document_generate
Client MCP ──► document_generate(...)
│
▼
auth: valida API key (401 se KO)
│
▼
Pydantic: valida shape input
│
▼
renderer.generate():
1. template_store.get(name)
2. validate variables (strict)
3. gen_id = uuid4()
4. per ogni var image:
- decode base64, valida mime/size
- salva /data/generated/<gen_id>/<var>.<ext>
- insert ephemeral_assets (expires_at)
- sostituisce var con URL assoluto
5. rewrite asset paths del template
6. assembla prompt finale
7. llm_client.chat(prompt, model)
8. generation_store.record(success|error)
│
▼
Ritorna GenerationResult al client
Flusso secondario — accesso asset:
Quando il client (o il renderer downstream) dereferenzia :
GET /assets/<template>/<file>→ auth check → serve file da{DATA_DIR}/templates/<template>/assets/.GET /generated/<gen_id>/<file>→ auth check → serve file. 410 Gone se record scaduto.
Anche gli asset richiedono API key: il Markdown non è pubblico.
7. Gestione errori
Errori di validazione (pre-LLM, nessuna registrazione)
| Caso | Errore |
|---|---|
| API key mancante/errata | HTTP 401 invalid_api_key |
template_name non esiste |
TemplateNotFound |
| Nome template invalido | InvalidTemplateName |
| Frontmatter YAML malformato | InvalidFrontmatter (con dettagli Pydantic) |
required_variables mancanti |
MissingVariables: [lista] |
| Variabile con tipo sbagliato | InvalidVariableType: <var> expected <type> |
| Immagine base64 non decodificabile | InvalidImageEncoding: <var> |
| Mime image non supportato | UnsupportedImageMime |
Immagine > MAX_IMAGE_SIZE_MB |
ImageTooLarge |
Errori di generazione (post-validazione)
| Caso | Comportamento |
|---|---|
| I/O salvataggio asset | Rollback file già scritti, AssetStorageError |
| OpenRouter timeout | Retry 3x backoff (1s, 2s, 4s), poi LLMTimeout |
| OpenRouter 5xx | Retry 3x, poi LLMUpstreamError |
| OpenRouter 401/403 (config server) | No retry, LLMAuthError |
| OpenRouter 429 | Retry backoff lungo (5s, 10s, 20s), poi LLMRateLimit |
| Risposta non parseable | LLMInvalidResponse |
| Risposta vuota | LLMEmptyResponse |
Ogni fallimento post-validazione → record generations con success=0. Asset efimeri non rimossi (scadranno col TTL).
Errori tool CRUD template
| Caso | Errore |
|---|---|
| Create su nome esistente | TemplateAlreadyExists |
| Update/delete su nome inesistente | TemplateNotFound |
| I/O filesystem | TemplateStorageError |
Errori serving asset HTTP
| Caso | Codice |
|---|---|
| Missing/invalid API key | 401 |
| Path traversal | 400 |
/assets/<template>/<file> non trovato |
404 |
/generated/<gen_id>/<file>: record ephemeral_assets scaduto o gen_id/file assente |
410 Gone |
Semantica: gli asset dei template sono persistenti, la loro assenza è 404. Gli asset generati sono effimeri: assenti = già esistiti o mai esistiti → 410 Gone in entrambi i casi (il client riconosce che è scaduto o invalido, non deve riprovare con path diverso).
Filosofia
- Nessuna fallback silenziosa. Validazione strict → errore esplicito, niente chiamata LLM sprecata.
- Retry solo su errori transitori upstream. Mai su 4xx.
- Codici errore sono stringhe ben definite: il client sa cosa correggere.
- Errori di config server (LLM auth, fs non scrivibile) → log ERROR, 500 con codice.
8. Strategia di test
Piramide
┌────────────┐
│ E2E (1-2)│ Container Docker reale
├────────────┤
│ Integration│ FastMCP test client + mock OpenRouter
├────────────┤
│ Unit │ Moduli isolati
└────────────┘
Unit
test_template_store.py— CRUD, nomi invalidi, frontmatter malformato, gestione asset dir.test_renderer.py— validazione strict, shape image, rewrite asset paths, assemblaggio prompt, LLM mock per verifica modello scelto.test_auth.py— header presente/assente,/healthsenza auth.test_generation_store.py— insert, query, TTL cleanup conexpires_atforzato.test_llm_client.py— retry su 5xx/timeout/429, no retry su 4xx, parsing usage.
Integration
test_mcp_tools.py— tool call end-to-end via FastMCP test client, ogni tool happy path + error paths.test_http_routes.py— assets auth, generated fresco vs scaduto (410), health sempre 200.test_document_generate_flow.py— create template → generate → verifica record + file + asset URL 200.
E2E
test_e2e_docker.py— build container, compose up, client MCP reale, OpenRouter mockato via env var URL.
Copertura
Target 85%+ sui moduli logici. main.py esente.
Cosa NON testiamo
- OpenRouter reale (costi, flaky).
- Qualità output LLM (responsabilità del modello).
- Performance/load (inserire se nascerà bisogno).
Fixture principali
tmp_data_dir— DATA_DIR pulito per test.fake_template— factory template valido.mock_openrouter— respx con risposta + usage.client— FastMCP test client autenticato.
9. Deploy e operatività
Container
- Dockerfile basato su
python:3.11-slim. uvper installazione dipendenze.- Utente non-root.
- Porta interna esposta: 8000.
- Volume:
/data.
docker-compose
services:
docugen-mcp:
build: .
restart: unless-stopped
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
TLS / reverse proxy
Fuori scope del container. Esempi:
- Traefik con label Docker.
- Nginx con Let's Encrypt.
- Caddy (TLS automatico).
Il reverse proxy termina HTTPS e inoltra a docugen-mcp:8000. PUBLIC_BASE_URL nell'env var deve puntare al dominio esterno (https://mcp.tuavps.com).
Configurazione client MCP
Claude Code:
claude mcp add --transport http docugen-mcp https://mcp.tuavps.com/mcp \
--header "Authorization: Bearer <api-key>"
Claude Desktop: entry in claude_desktop_config.json con URL + header.
Backup
- Cartella
{DATA_DIR}/templates/→ rsync periodico o copia nel git. docugen_mcp.db→ dump SQLite regolare.{DATA_DIR}/generated/→ effimero, non critico.
Osservabilità
- Log strutturati (JSON) su stdout. Tracciamento via docker logs.
- Metriche dai record
generations: spesa mensile, success rate, latenza media per modello. Query SQL ad-hoc o piccolo endpoint admin in fasi successive.
10. Stack tecnologico
| Layer | Tecnologia |
|---|---|
| Runtime | Python 3.11+ |
| Package manager | uv |
| Validazione | Pydantic v2, Pydantic Settings |
| MCP | mcp SDK (FastMCP) |
| HTTP (sub-app) | FastAPI |
| Client HTTP | httpx (async) |
| Retry | httpx + wrapper custom (retry exponential backoff) |
| DB | SQLite via aiosqlite |
| Immagini | Pillow (validazione mime/dimensione) |
| Test | pytest, pytest-asyncio, respx |
| Deploy | Docker, docker-compose |
| Reverse proxy | Nginx/Traefik/Caddy (fuori container) |