diff --git a/nexus-mcp/lib/config.py b/nexus-mcp/lib/config.py
index 44ec574..ed2a354 100644
--- a/nexus-mcp/lib/config.py
+++ b/nexus-mcp/lib/config.py
@@ -135,8 +135,8 @@ class ReportConfig(BaseSettings):
env_file_encoding="utf-8",
extra="ignore"
)
-
- output_dir: Path = Path("./reports")
+
+ output_dir: Path = Path("./output-reports")
@field_validator("output_dir", mode="before")
@classmethod
diff --git a/nexus-mcp/pyproject.toml b/nexus-mcp/pyproject.toml
index 4f4f29e..f160dc4 100644
--- a/nexus-mcp/pyproject.toml
+++ b/nexus-mcp/pyproject.toml
@@ -32,6 +32,14 @@ test = [
"pytest-cov>=5.0.0",
"pytest-asyncio>=0.24.0",
]
+report = [
+ # Markdown → HTML conversion
+ "markdown>=3.6",
+ # HTML → PDF (Windows: also needs GTK3 DLLs — see weasyprint docs)
+ "weasyprint>=62.0",
+ # Markdown → DOCX
+ "python-docx>=1.1.0",
+]
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/nexus-mcp/src/report_templates/__init__.py b/nexus-mcp/src/report_templates/__init__.py
new file mode 100644
index 0000000..7f19b00
--- /dev/null
+++ b/nexus-mcp/src/report_templates/__init__.py
@@ -0,0 +1,28 @@
+"""report_templates — multi-format rendering for Nexus output reports.
+
+Usage
+-----
+from report_templates import ReportFormat, render
+
+# Markdown (default — no extra deps)
+md_str = render(markdown, ReportFormat.MD, title="My Report")
+
+# HTML (requires: pip install markdown)
+html_str = render(markdown, ReportFormat.HTML, title="My Report")
+
+# PDF (requires: pip install weasyprint markdown)
+# On Windows: also install GTK3 runtime DLLs
+# https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows
+pdf_bytes = render(markdown, ReportFormat.PDF, title="My Report")
+
+# DOCX (requires: pip install python-docx)
+docx_bytes = render(markdown, ReportFormat.DOCX, title="My Report")
+
+Install all at once
+-------------------
+pip install "nexus-mcp[report]"
+"""
+
+from ._renderer import ReportFormat, render
+
+__all__ = ["ReportFormat", "render"]
diff --git a/nexus-mcp/src/report_templates/_renderer.py b/nexus-mcp/src/report_templates/_renderer.py
new file mode 100644
index 0000000..2f8bb56
--- /dev/null
+++ b/nexus-mcp/src/report_templates/_renderer.py
@@ -0,0 +1,288 @@
+"""Multi-format report renderer for Nexus MCP.
+
+Converts a markdown string to the requested output format.
+All non-markdown renderers have optional dependencies guarded with
+clear ImportError messages — the server starts fine without them.
+
+Formats
+-------
+md — plain markdown (no extra deps)
+html — standalone HTML with Wheels-branded CSS
+ requires: pip install markdown
+pdf — PDF generated from the branded HTML
+ requires: pip install weasyprint markdown
+ Windows note: GTK3 runtime DLLs are also required
+ https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows
+docx — Word document
+ requires: pip install python-docx
+"""
+
+from __future__ import annotations
+
+import io
+from enum import Enum
+
+
+# ── Brand / style constants ───────────────────────────────────────────────────
+
+_BRAND_PRIMARY = "#003865" # Wheels dark blue
+_BRAND_ACCENT = "#0066cc" # Wheels mid blue
+_BRAND_ROW_ALT = "#f5f8fc" # table row stripe
+
+_CSS = f"""
+body {{
+ font-family: 'Segoe UI', Arial, sans-serif;
+ margin: 40px auto;
+ max-width: 960px;
+ color: #222;
+ line-height: 1.55;
+}}
+h1 {{
+ color: {_BRAND_PRIMARY};
+ border-bottom: 3px solid {_BRAND_PRIMARY};
+ padding-bottom: 8px;
+ margin-bottom: 4px;
+}}
+h2 {{
+ color: {_BRAND_ACCENT};
+ margin-top: 32px;
+}}
+h3 {{ color: #444; }}
+table {{
+ border-collapse: collapse;
+ width: 100%;
+ margin: 16px 0;
+ font-size: 0.92em;
+}}
+th {{
+ background: {_BRAND_PRIMARY};
+ color: #fff;
+ padding: 8px 12px;
+ text-align: left;
+ font-weight: 600;
+}}
+td {{
+ padding: 7px 12px;
+ border-bottom: 1px solid #ddd;
+}}
+tr:nth-child(even) td {{
+ background: {_BRAND_ROW_ALT};
+}}
+code, pre {{
+ background: #f0f0f0;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ font-family: 'Consolas', 'Courier New', monospace;
+}}
+ul, ol {{ padding-left: 1.4em; }}
+li {{ margin-bottom: 4px; }}
+.meta {{
+ color: #666;
+ font-size: 0.88em;
+ margin-bottom: 20px;
+}}
+"""
+
+_HTML_WRAPPER = """\
+
+
+
+
+
+ {title}
+
+
+
+{body}
+
+
+"""
+
+
+# ── Public API ────────────────────────────────────────────────────────────────
+
+class ReportFormat(str, Enum):
+ MD = "md"
+ HTML = "html"
+ PDF = "pdf"
+ DOCX = "docx"
+
+ @property
+ def extension(self) -> str:
+ return self.value
+
+ @property
+ def is_binary(self) -> bool:
+ return self in (ReportFormat.PDF, ReportFormat.DOCX)
+
+
+def render(
+ markdown_content: str,
+ fmt: ReportFormat,
+ title: str = "Report",
+) -> bytes | str:
+ """Render *markdown_content* to the requested format.
+
+ Returns:
+ str for MD and HTML
+ bytes for PDF and DOCX
+ """
+ if fmt == ReportFormat.MD:
+ return markdown_content
+
+ html = _md_to_html(markdown_content, title)
+
+ if fmt == ReportFormat.HTML:
+ return html
+
+ if fmt == ReportFormat.PDF:
+ return _html_to_pdf(html)
+
+ if fmt == ReportFormat.DOCX:
+ return _md_to_docx(markdown_content, title)
+
+ raise ValueError(f"Unsupported format: {fmt!r}")
+
+
+# ── Internal renderers ────────────────────────────────────────────────────────
+
+def _md_to_html(md: str, title: str) -> str:
+ """Convert markdown string to a full, styled HTML document."""
+ try:
+ import markdown as _md_lib
+ except ImportError as exc:
+ raise ImportError(
+ "HTML/PDF output requires the 'markdown' package.\n"
+ " pip install markdown\n"
+ " or: pip install 'nexus-mcp[report]'"
+ ) from exc
+
+ body = _md_lib.markdown(
+ md,
+ extensions=["tables", "fenced_code", "nl2br"],
+ )
+ return _HTML_WRAPPER.format(title=title, css=_CSS, body=body)
+
+
+def _html_to_pdf(html: str) -> bytes:
+ """Convert a full HTML string to PDF bytes using WeasyPrint."""
+ try:
+ from weasyprint import HTML as _HTML
+ except ImportError as exc:
+ raise ImportError(
+ "PDF output requires WeasyPrint.\n"
+ " pip install weasyprint\n"
+ " or: pip install 'nexus-mcp[report]'\n"
+ "\n"
+ "Windows: GTK3 runtime DLLs are also required:\n"
+ " https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows"
+ ) from exc
+
+ return _HTML(string=html).write_pdf()
+
+
+def _md_to_docx(md: str, title: str) -> bytes:
+ """Convert markdown to a .docx document using python-docx.
+
+ Handles: headings (#/##/###), bullet lists (- …), pipe tables,
+ key-value pairs (- Key: Value), and plain paragraphs.
+ """
+ try:
+ from docx import Document
+ from docx.shared import Pt, RGBColor
+ from docx.oxml.ns import qn
+ from docx.oxml import OxmlElement
+ except ImportError as exc:
+ raise ImportError(
+ "DOCX output requires python-docx.\n"
+ " pip install python-docx\n"
+ " or: pip install 'nexus-mcp[report]'"
+ ) from exc
+
+ doc = Document()
+
+ # ── document title ────────────────────────────────────────────────────────
+ heading = doc.add_heading(title, level=0)
+ heading.runs[0].font.color.rgb = RGBColor(0x00, 0x38, 0x65)
+
+ lines = md.splitlines()
+ i = 0
+
+ while i < len(lines):
+ line = lines[i]
+
+ # Skip blank lines
+ if not line.strip():
+ i += 1
+ continue
+
+ # Heading 1
+ if line.startswith("# "):
+ doc.add_heading(line[2:].strip(), level=1)
+ i += 1
+ continue
+
+ # Heading 2
+ if line.startswith("## "):
+ doc.add_heading(line[3:].strip(), level=2)
+ i += 1
+ continue
+
+ # Heading 3
+ if line.startswith("### "):
+ doc.add_heading(line[4:].strip(), level=3)
+ i += 1
+ continue
+
+ # Bullet
+ if line.startswith("- "):
+ doc.add_paragraph(line[2:].strip(), style="List Bullet")
+ i += 1
+ continue
+
+ # Pipe table: collect header + separator + data rows
+ if line.startswith("|") and i + 1 < len(lines) and lines[i + 1].startswith("|---"):
+ headers = [c.strip() for c in line.strip().strip("|").split("|")]
+ i += 2 # skip separator row
+ data_rows: list[list[str]] = []
+ while i < len(lines) and lines[i].startswith("|"):
+ data_rows.append(
+ [c.strip() for c in lines[i].strip().strip("|").split("|")]
+ )
+ i += 1
+
+ ncols = len(headers)
+ t = doc.add_table(rows=1 + len(data_rows), cols=ncols)
+ t.style = "Table Grid"
+
+ # Header row — bold + brand colour fill
+ hdr_row = t.rows[0]
+ for j, cell_text in enumerate(headers):
+ cell = hdr_row.cells[j]
+ cell.text = cell_text
+ run = cell.paragraphs[0].runs[0]
+ run.bold = True
+ run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
+ # Fill background
+ tc_pr = cell._tc.get_or_add_tcPr()
+ shd = OxmlElement("w:shd")
+ shd.set(qn("w:val"), "clear")
+ shd.set(qn("w:color"), "auto")
+ shd.set(qn("w:fill"), "003865")
+ tc_pr.append(shd)
+
+ for r_idx, row_data in enumerate(data_rows):
+ for j, cell_text in enumerate(row_data):
+ if j < ncols:
+ t.rows[r_idx + 1].cells[j].text = cell_text
+
+ continue
+
+ # Fallback: plain paragraph
+ doc.add_paragraph(line)
+ i += 1
+
+ buf = io.BytesIO()
+ doc.save(buf)
+ return buf.getvalue()
diff --git a/nexus-mcp/src/shards/reports.py b/nexus-mcp/src/shards/reports.py
index b449f62..349a734 100644
--- a/nexus-mcp/src/shards/reports.py
+++ b/nexus-mcp/src/shards/reports.py
@@ -6,10 +6,11 @@ import logging
import re
from datetime import datetime, timezone
from pathlib import Path
-from typing import Optional
+from typing import Literal, Optional
import aiofiles
from mcp.server.fastmcp import FastMCP
+from report_templates import ReportFormat, render as _render
logger = logging.getLogger("nexus-mcp.reports")
@@ -55,25 +56,34 @@ def register(mcp: FastMCP) -> None:
content: str,
subfolder: Optional[str] = None,
filename: Optional[str] = None,
+ format: Literal["md", "html", "pdf", "docx"] = "md",
) -> dict:
"""Save markdown content to the configured reports directory.
- Accepts a large markdown string, writes it as a .md file, and returns
- only a small metadata dict (path, filename, size_bytes) so the chat
- context stays lightweight regardless of report size.
+ Accepts a markdown string, converts it to the requested format, writes
+ it to disk, and returns a small metadata dict so the chat context stays
+ lightweight regardless of report size.
Args:
title: Human-readable report title. Used to derive filename when
'filename' is not provided.
- content: Full markdown body to write to disk.
+ content: Full markdown body to render and write to disk.
subfolder: Optional subdirectory under the output dir (e.g. "identity").
Non-alphanumeric characters are replaced with hyphens.
filename: Override filename base (without extension). Defaults to a
slugified version of 'title' with an ISO timestamp suffix.
+ format: Output format — "md" (default), "html", "pdf", or "docx".
+ Extra packages are required for non-markdown formats:
+ html/pdf → pip install markdown (+ weasyprint for pdf)
+ pdf → also needs GTK3 DLLs on Windows
+ docx → pip install python-docx
+ Install all at once: pip install "nexus-mcp[report]"
Returns:
{"status": "saved", "path": str, "filename": str, "size_bytes": int}
"""
+ fmt = ReportFormat(format)
+
base_dir = _output_dir
if subfolder:
safe_sub = re.sub(r"[^\w\-]", "-", subfolder.strip())
@@ -83,7 +93,7 @@ def register(mcp: FastMCP) -> None:
safe_filename = (
re.sub(r"[^\w\-]", "-", filename.strip()) if filename else _slugify(title)
)
- final_filename = f"{safe_filename}-{timestamp}.md"
+ final_filename = f"{safe_filename}-{timestamp}.{fmt.extension}"
abs_path = (base_dir / final_filename).resolve()
# Safety guard: keep all writes inside the permitted output tree
@@ -97,8 +107,16 @@ def register(mcp: FastMCP) -> None:
abs_path.parent.mkdir(parents=True, exist_ok=True)
- async with aiofiles.open(abs_path, "w", encoding="utf-8") as f:
- await f.write(content)
+ try:
+ rendered = _render(content, fmt, title=title)
+ except ImportError as exc:
+ return {"status": "error", "error": str(exc)}
+
+ if fmt.is_binary:
+ abs_path.write_bytes(rendered)
+ else:
+ async with aiofiles.open(abs_path, "w", encoding="utf-8") as f:
+ await f.write(rendered)
size_bytes = abs_path.stat().st_size
logger.info("save_report: wrote %d bytes → %s", size_bytes, abs_path)