# 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// │ │ │ ├── template.md │ │ │ └── assets/* │ │ ├── generated// │ │ │ └── . (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 ` 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//`. - 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/` → `{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/ │ └── / │ ├── template.md # frontmatter + body │ └── assets/ │ └── logo.png # risorse statiche del template ├── generated/ │ └── / # uuid4, uno per generazione │ └── . # 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": "", "markdown": "...output MD...", "model": "anthropic/claude-sonnet-4", "tokens": {"in": 1234, "out": 2345}, "cost_usd": 0.0123, "ephemeral_assets_urls": [ "https://mcp.vps/generated//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//. - 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/