Files
ArcaSuite/docs/mcp-docugen-design.md
Adriano 7b169fb8db chore: organize repo as uv workspace multi-MCP monorepo
Layout stile CerberoSuite/Cerbero:
- services/ per MCP workspace members (futuri: mcp-docugen, mcp-convert, mcp-inbox)
- docs/ flat con design+implementation per componente
- scripts/, secrets/ placeholder
- pyproject.toml root (uv workspace vuoto + ruff + pytest)
- .gitignore, .env.example, .mcp.json.example, README.md

Rinominate le doc: Docs/<comp>/2026-04-21-*.md -> docs/<comp>-*.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:06:38 +02:00

601 lines
23 KiB
Markdown

# 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
1. [Panoramica](#1-panoramica)
2. [Architettura generale](#2-architettura-generale)
3. [Componenti interni](#3-componenti-interni)
4. [Modello dati](#4-modello-dati)
5. [API MCP — tool esposti](#5-api-mcp--tool-esposti)
6. [Dataflow `document_generate`](#6-dataflow-document_generate)
7. [Gestione errori](#7-gestione-errori)
8. [Strategia di test](#8-strategia-di-test)
9. [Deploy e operatività](#9-deploy-e-operatività)
10. [Stack tecnologico](#10-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_variables` e 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:
```yaml
---
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:
1. `template_store.get(template_name)``(frontmatter, body, assets_dir)`.
2. Validazione variabili (strict): ogni `required_variables` dev'essere presente con il tipo dichiarato. Fallimento → eccezione tipizzata senza chiamare LLM.
3. Generazione `gen_id = uuid4()`.
4. Per ogni variabile di tipo `image`: decodifica base64, valida mime, salva in `{DATA_DIR}/generated/{gen_id}/{var_name}.{ext}`, registra in `ephemeral_assets` con `expires_at = now + ASSET_TTL_DAYS`, sostituisce la variabile con URL assoluto `{PUBLIC_BASE_URL}/generated/{gen_id}/{var_name}.{ext}`.
5. Rewrite asset paths del template: `./assets/<file>``{PUBLIC_BASE_URL}/assets/{template_name}/{file}`.
6. Assembla prompt finale:
- **system**: body del template con placeholder sostituiti.
- **user**: contenuto grezzo + variabili formattate + `instructions` opzionali.
7. Chiama `llm_client.chat(...)` con il modello selezionato.
8. Registra la generazione in `generation_store` (success o error).
9. 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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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 `![alt](https://mcp.vps/assets/...)`:
- `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, `/health` senza auth.
- `test_generation_store.py` — insert, query, TTL cleanup con `expires_at` forzato.
- `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`.
- `uv` per installazione dipendenze.
- Utente non-root.
- Porta interna esposta: 8000.
- Volume: `/data`.
### docker-compose
```yaml
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:
```bash
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) |