feat(mcp-docugen): Task 7-10 renderer, http_routes, mcp_tools, main bootstrap

- Renderer: orchestratore generate() — validazione strict variabili,
  materializzazione image vars come asset effimeri su disco + URL rewrite,
  asset paths template da ./assets/X -> {PUBLIC_BASE_URL}/assets/<t>/X,
  integrazione LLM error -> record success=0
- FastAPI sub-app: GET /health (no auth), /assets/{t}/{f} (auth+traversal check),
  /generated/{gen_id}/{f} (410 su scaduto o mancante)
- FastMCP server con 6 tool: template_create/update/delete/list/get,
  document_generate. Tools esposti anche via mcp.tools dict per test.
- main.build_app() compone http_app + FastMCP mount su /mcp + auth middleware
  + lifespan cleanup task TTL (24h). run() entry point per script console.

68 test passed. Build Docker arca-mcp-docugen:dev verificata,
/health endpoint risponde correttamente nel container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 12:27:16 +02:00
parent 9e80a20063
commit e8705dcd0b
8 changed files with 1124 additions and 0 deletions
@@ -0,0 +1,54 @@
from __future__ import annotations
import re
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from mcp_docugen.generation_store import GenerationStore
from mcp_docugen.template_store import InvalidTemplateName, TemplateStore
_SAFE_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def build_http_app(
template_store: TemplateStore,
generation_store: GenerationStore,
) -> FastAPI:
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/assets/{template_name}/{filename}")
async def get_template_asset(template_name: str, filename: str):
if not _SAFE_SEGMENT_RE.match(template_name) or not _SAFE_SEGMENT_RE.match(
filename
):
raise HTTPException(status_code=400, detail="invalid path")
try:
path = template_store.asset_path(template_name, filename)
except (ValueError, InvalidTemplateName) as exc:
raise HTTPException(status_code=400, detail="invalid path") from exc
if not path.exists():
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path)
@app.get("/generated/{gen_id}/{filename}")
async def get_generated_asset(gen_id: str, filename: str):
if not _SAFE_SEGMENT_RE.match(gen_id) or not _SAFE_SEGMENT_RE.match(filename):
raise HTTPException(status_code=400, detail="invalid path")
info = await generation_store.get_ephemeral_asset(gen_id, filename)
if info is None or info.is_expired or not Path(info.file_path).exists():
return JSONResponse(
status_code=410,
content={
"error": "gone",
"reason": "ephemeral asset expired or not found",
},
)
return FileResponse(info.file_path, media_type=info.mime)
return app
@@ -0,0 +1,107 @@
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from mcp_docugen.auth import ApiKeyAuthMiddleware
from mcp_docugen.config import Settings
from mcp_docugen.generation_store import GenerationStore
from mcp_docugen.http_routes import build_http_app
from mcp_docugen.llm_client import OpenRouterClient
from mcp_docugen.mcp_tools import build_mcp_server
from mcp_docugen.renderer import Renderer
from mcp_docugen.template_store import TemplateStore
logger = logging.getLogger("mcp_docugen")
async def build_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings()
settings.data_dir.mkdir(parents=True, exist_ok=True)
templates_dir = settings.data_dir / "templates"
generated_dir = settings.data_dir / "generated"
db_path = settings.data_dir / "mcp_docugen.db"
template_store = TemplateStore(base_dir=templates_dir)
generation_store = GenerationStore(
db_path=db_path, generated_dir=generated_dir
)
await generation_store.init()
llm = OpenRouterClient(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
timeout=settings.llm_timeout_seconds,
)
renderer = Renderer(
template_store=template_store,
generation_store=generation_store,
llm=llm,
public_base_url=settings.public_base_url,
default_model=settings.llm_model_default,
asset_ttl_days=settings.asset_ttl_days,
max_image_size_mb=settings.max_image_size_mb,
)
mcp = build_mcp_server(template_store, renderer)
@asynccontextmanager
async def lifespan(app: FastAPI):
cleanup_task = asyncio.create_task(
_periodic_cleanup(generation_store, interval_seconds=24 * 3600)
)
try:
yield
finally:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
app = build_http_app(template_store, generation_store)
app.router.lifespan_context = lifespan
app.mount("/mcp", mcp.streamable_http_app())
app.add_middleware(
ApiKeyAuthMiddleware,
api_key=settings.api_key,
exempt_paths={"/health"},
)
app.state.settings = settings
app.state.template_store = template_store
app.state.generation_store = generation_store
app.state.renderer = renderer
app.state.llm = llm
return app
async def _periodic_cleanup(
generation_store: GenerationStore, interval_seconds: int
) -> None:
while True:
try:
removed = await generation_store.cleanup_expired()
if removed:
logger.info("cleanup_expired removed %d assets", removed)
except Exception as exc: # noqa: BLE001
logger.error("cleanup task error: %s", exc)
await asyncio.sleep(interval_seconds)
def run() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "9100"))
app = asyncio.run(build_app())
uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,92 @@
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from mcp_docugen.models import TemplateFrontmatter
from mcp_docugen.renderer import Renderer
from mcp_docugen.template_store import TemplateStore
def build_mcp_server(
template_store: TemplateStore, renderer: Renderer
) -> FastMCP:
mcp = FastMCP("mcp-docugen")
@mcp.tool()
async def template_create(
name: str,
frontmatter: dict,
body: str,
assets: list[dict] | None = None,
) -> dict:
"""Create a new template. Fails if the name already exists."""
fm = TemplateFrontmatter(**frontmatter)
await template_store.create(
name=name, frontmatter=fm, body=body, assets=assets
)
return {"name": name, "status": "created"}
@mcp.tool()
async def template_update(
name: str,
frontmatter: dict,
body: str,
assets: list[dict] | None = None,
) -> dict:
"""Update an existing template. Replaces frontmatter, body, and (if given) assets."""
fm = TemplateFrontmatter(**frontmatter)
await template_store.update(
name=name, frontmatter=fm, body=body, assets=assets
)
return {"name": name, "status": "updated"}
@mcp.tool()
async def template_delete(name: str) -> dict:
"""Delete a template and all its assets."""
await template_store.delete(name)
return {"name": name, "status": "deleted"}
@mcp.tool()
async def template_get(name: str) -> dict:
"""Return a template's frontmatter, body, and asset list."""
loaded = await template_store.get(name)
return {
"name": loaded.frontmatter.name,
"frontmatter": loaded.frontmatter.model_dump(exclude_none=True),
"body": loaded.body,
"assets": [a.model_dump() for a in loaded.assets],
"updated_at": loaded.updated_at.isoformat(),
}
@mcp.tool()
async def template_list() -> list[dict]:
"""List all templates with name, description, and updated_at."""
summaries = await template_store.list()
return [s.model_dump(mode="json") for s in summaries]
@mcp.tool()
async def document_generate(
template_name: str,
content_md: str,
variables: dict,
instructions: str | None = None,
) -> dict:
"""Generate a Markdown document from a template, content, and variables."""
result = await renderer.generate(
template_name=template_name,
content_md=content_md,
variables=variables,
instructions=instructions,
)
return result.model_dump(mode="json")
mcp.tools = {
"template_create": template_create,
"template_update": template_update,
"template_delete": template_delete,
"template_get": template_get,
"template_list": template_list,
"document_generate": document_generate,
}
return mcp
@@ -0,0 +1,255 @@
from __future__ import annotations
import base64
import binascii
import re
import uuid
import aiofiles
from pydantic import ValidationError
from mcp_docugen.generation_store import (
EphemeralAssetRecord,
GenerationRecord,
GenerationStore,
)
from mcp_docugen.llm_client import LLMError, OpenRouterClient
from mcp_docugen.models import (
GenerationResult,
ImageVariable,
TemplateFrontmatter,
TokenUsage,
)
from mcp_docugen.template_store import TemplateStore
class RendererError(Exception):
pass
class MissingVariables(RendererError):
def __init__(self, missing: list[str]) -> None:
super().__init__(f"missing required variables: {missing}")
self.missing = missing
class InvalidVariableType(RendererError):
pass
class InvalidImageEncoding(RendererError):
pass
class ImageTooLarge(RendererError):
pass
class AssetStorageError(RendererError):
pass
_ASSET_TEMPLATE_REF_RE = re.compile(r"\.\/assets\/([A-Za-z0-9._-]+)")
class Renderer:
def __init__(
self,
template_store: TemplateStore,
generation_store: GenerationStore,
llm: OpenRouterClient,
public_base_url: str,
default_model: str,
asset_ttl_days: int,
max_image_size_mb: int,
) -> None:
self.template_store = template_store
self.generation_store = generation_store
self.llm = llm
self.public_base_url = public_base_url.rstrip("/")
self.default_model = default_model
self.asset_ttl_days = asset_ttl_days
self.max_image_size_bytes = max_image_size_mb * 1024 * 1024
async def generate(
self,
template_name: str,
content_md: str,
variables: dict,
instructions: str | None,
) -> GenerationResult:
loaded = await self.template_store.get(template_name)
gen_id = str(uuid.uuid4())
validated = self._validate_variables(loaded.frontmatter, variables)
ephemeral_urls, var_strings = await self._materialize_image_variables(
gen_id, validated
)
system_prompt = self._build_system_prompt(
loaded.frontmatter, loaded.body, var_strings
)
user_prompt = self._build_user_prompt(content_md, var_strings, instructions)
model = loaded.frontmatter.model or self.default_model
try:
response = await self.llm.chat(
model=model, system=system_prompt, user=user_prompt
)
except LLMError as exc:
await self.generation_store.record_generation(
GenerationRecord(
id=gen_id,
template_name=template_name,
model=model,
tokens_in=0,
tokens_out=0,
cost_usd=0.0,
success=False,
error_msg=f"{type(exc).__name__}: {exc}",
)
)
raise
await self.generation_store.record_generation(
GenerationRecord(
id=gen_id,
template_name=template_name,
model=response.model,
tokens_in=response.tokens_in,
tokens_out=response.tokens_out,
cost_usd=response.cost_usd,
success=True,
error_msg=None,
)
)
expires_at = None
if ephemeral_urls:
from pathlib import Path
info = await self.generation_store.get_ephemeral_asset(
gen_id, Path(ephemeral_urls[0]).name
)
expires_at = info.expires_at if info else None
return GenerationResult(
generation_id=gen_id,
markdown=response.text,
model=response.model,
tokens=TokenUsage(input=response.tokens_in, output=response.tokens_out),
cost_usd=response.cost_usd,
ephemeral_assets_urls=ephemeral_urls,
ephemeral_expires_at=expires_at,
)
def _validate_variables(
self, fm: TemplateFrontmatter, variables: dict
) -> dict:
missing = [
v.name for v in fm.required_variables if v.name not in variables
]
if missing:
raise MissingVariables(missing)
validated: dict = {}
for var_def in fm.required_variables:
raw = variables[var_def.name]
if var_def.type == "string":
if not isinstance(raw, str):
raise InvalidVariableType(
f"{var_def.name}: expected string, got {type(raw).__name__}"
)
validated[var_def.name] = raw
elif var_def.type == "image":
if not isinstance(raw, dict):
raise InvalidVariableType(
f"{var_def.name}: expected image object, got {type(raw).__name__}"
)
try:
img = ImageVariable(**raw)
except ValidationError as exc:
raise InvalidVariableType(f"{var_def.name}: {exc}") from exc
validated[var_def.name] = img
return validated
async def _materialize_image_variables(
self, gen_id: str, validated: dict
) -> tuple[list[str], dict[str, str]]:
urls: list[str] = []
var_strings: dict[str, str] = {}
gen_dir = self.generation_store.generated_dir / gen_id
for name, value in validated.items():
if isinstance(value, ImageVariable):
try:
raw_bytes = base64.b64decode(value.data_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise InvalidImageEncoding(f"{name}: {exc}") from exc
if len(raw_bytes) > self.max_image_size_bytes:
raise ImageTooLarge(
f"{name}: {len(raw_bytes)} bytes > {self.max_image_size_bytes}"
)
ext = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}[value.mime]
filename = f"{name}.{ext}"
file_path = gen_dir / filename
gen_dir.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(file_path, "wb") as f:
await f.write(raw_bytes)
except OSError as exc:
raise AssetStorageError(str(exc)) from exc
await self.generation_store.register_ephemeral_asset(
EphemeralAssetRecord(
generation_id=gen_id,
var_name=name,
file_path=str(file_path),
mime=value.mime,
ttl_days=self.asset_ttl_days,
)
)
url = (
f"{self.public_base_url}/generated/{gen_id}/{filename}"
)
urls.append(url)
var_strings[name] = url
else:
var_strings[name] = str(value)
return urls, var_strings
def _build_system_prompt(
self,
fm: TemplateFrontmatter,
body: str,
var_strings: dict[str, str],
) -> str:
template_name = fm.name
def _replace_asset(match: re.Match) -> str:
filename = match.group(1)
return f"{self.public_base_url}/assets/{template_name}/{filename}"
resolved = _ASSET_TEMPLATE_REF_RE.sub(_replace_asset, body)
for name, value in var_strings.items():
resolved = resolved.replace(f"{{{{{name}}}}}", value)
return resolved
def _build_user_prompt(
self,
content_md: str,
var_strings: dict[str, str],
instructions: str | None,
) -> str:
parts = ["## Raw content", content_md]
if var_strings:
parts.append("## Variables")
for name, value in var_strings.items():
parts.append(f"- {name}: {value}")
if instructions:
parts.append("## Additional instructions")
parts.append(instructions)
return "\n\n".join(parts)