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:
@@ -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)
|
||||
Reference in New Issue
Block a user