apollo-pdf-creator
# apollo-pdf-creator
[](https://github.com/kakalition/apollo-pdf-creator/actions/workflows/ci.yml)
[](LICENSE)
[](pyproject.toml)
[](https://modelcontextprotocol.io)
A standalone, **stateless** [Model Context Protocol](https://modelcontextprotocol.io)
server that renders **PDF documents** — reports, invoices, letters, resumes, and
book-style documents — from a compact JSON spec, and converts Markdown to PDF.
It is the document engine that used to ship as the `pdf-creator`
[Agent Skill](https://skills.sh) in
[apollo-pack](https://github.com/kakalition/apollo-pack), re-homed as an MCP.
The render core (`scripts/_lib.py`, `scripts/_engine.py`, `scripts/_themes.py`,
`scripts/templates.py`) is reused as-is; reportlab is pulled in lazily only for
the actual render.
- **Spec in, PDF out** — one JSON object (or a Markdown string), one PDF
(returned inline and written to disk).
- **Stateless** — no database, no render history, no state written; safe to run
as many copies as you like.
- **stdio or HTTP** — MCP over stdio by default, with Streamable HTTP (`/mcp`)
and SSE (`/sse`) transports built in.
- **Modern house style** — near-black type, generous spacing, one restrained
accent, hairline rules, and a family of themes (`default`, `report`,
`invoice`, `letter`, `resume`, `book`, `minimal`, `elegant`).
- **Rich blocks** — headings, lists, tables, images, code, callouts, columns,
TOC, cover pages, QR codes, barcodes, and more.
- **Discoverable** — themes, demo specs, and the spec schema are read-only MCP
resources, plus a `make-pdf` prompt.
## Requirements
- **Python 3.10+**. The MCP layer depends on `mcp`; rendering depends on
`reportlab` (and `Pillow` for extra image formats). Both are installed by
`uv sync`.
- Non-rendering calls (`validate_spec`, plus `create_pdf`'s validation step)
work without reportlab; a render fails fast with a `dependency_missing` error
if it is absent.
## Install
```bash
git clone https://github.com/kakalition/apollo-pdf-creator
cd apollo-pdf-creator
./install.sh
```
`install.sh` is idempotent: it checks for `uv`, runs `uv sync`, and prints
run/transport/inspector examples. `uv` is the only extra tool you need; install
it from <https://docs.astral.sh/uv/>.
## Transports
The server speaks MCP over **stdio** by default, and can serve the same API over
HTTP:
| Transport | Flag | Endpoint |
|---|---|---|
| stdio (default) | `--transport stdio` | stdin/stdout |
| Streamable HTTP | `--transport streamable-http` | `http://HOST:PORT/mcp` |
| HTTP + SSE | `--transport sse` | `http://HOST:PORT/sse` |
```bash
uv run apollo-pdf-creator # stdio
uv run apollo-pdf-creator --transport streamable-http --port 8000 # HTTP
uv run apollo-pdf-creator --transport sse --port 8000 # SSE
```
`--transport`, `--host` (default `127.0.0.1`), and `--port` (default `8000`)
also read `APOLLO_PDF_TRANSPORT`, `APOLLO_PDF_HOST`, and `APOLLO_PDF_PORT`; log
verbosity reads `APOLLO_PDF_LOG_LEVEL` (`DEBUG`…`CRITICAL`, default `INFO`). The
HTTP transports bind to loopback by default; put a reverse proxy (and auth) in
front before exposing them beyond the host.
## Use with an MCP client
Point a stdio client at the console script:
```json
{
"mcpServers": {
"apollo-pdf-creator": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/apollo-pdf-creator", "apollo-pdf-creator"]
}
}
}
```
Or run it over HTTP and point a streamable-HTTP client at
`http://127.0.0.1:8000/mcp`:
```bash
uv run apollo-pdf-creator --transport streamable-http --port 8000
```
Explore it interactively with the MCP Inspector:
```bash
uv run mcp dev src/apollo_pdf_creator/server.py
```
## Tools
| Tool | Arguments | Returns |
|---|---|---|
| `create_pdf` | `spec` (required), `out`, `home`, `theme`, `base_dir`, `allow_remote`, `force` | JSON metadata (path, pages, bytes, sha256, width/height, page size, orientation, theme, warnings, home) **and** an inline `application/pdf`. |
| `validate_spec` | `spec` (required), `strict` | JSON mirroring `render.py check`: `valid`, `spec_version`, `blocks`, `theme`, `warnings`; with `strict`, engine-resolved `flowables` and engine warnings. |
| `from_markdown` | `markdown` (required), `title`, `author`, `theme`, `toc`, `number_headings`, `header`, `footer`, `out`, `home`, `base_dir`, `allow_remote`, `force` | JSON metadata **and** an inline `application/pdf`. |
Defaults:
- `out` — `<output_dir>/<slug>.pdf`, named from `meta.title` (or `document`).
- `home` — `$PDF_CREATOR_HOME`, else `~/.local/share/pdf-creator`; read-only for
`fonts/` and `assets/`, and the default output root.
- `base_dir` — the server process cwd, used to resolve relative image/font paths.
- `author` (from_markdown) — the `PDF_CREATOR_AUTHOR` setting.
- `allow_remote` — off; the renderer will not fetch remote images unless asked.
- `force` — `false`; an existing `out` raises a `conflict` error.
### Example
```json
{
"spec": {
"spec_version": 1,
"theme": "report",
"meta": { "title": "Quarterly Report", "author": "Acme Analytics" },
"header": { "text": "Quarterly Report", "align": "right", "divider": true },
"footer": { "text": "Page {page} of {pages}", "align": "center" },
"toc": { "title": "Contents", "depth": 2 },
"content": [
{ "type": "heading", "level": 1, "text": "Executive summary" },
{ "type": "paragraph", "text": "Revenue grew **12%** quarter over quarter." },
{ "type": "table", "header": ["Region", "Revenue"], "zebra": true,
"rows": [["North America", "$4.1M"], ["Europe", "$2.6M"]] }
]
}
}
```
`create_pdf` returns a text block like:
```json
{
"out": "/home/you/.local/share/pdf-creator/output/Quarterly-Report.pdf",
"pages": 2,
"bytes": 28411,
"sha256": "6f1c…",
"width_pt": 595.28,
"height_pt": 841.89,
"page_size": "595.3x841.9",
"orientation": "portrait",
"theme": "report",
"warnings": [],
"home": "/home/you/.local/share/pdf-creator"
}
```
followed by an `application/pdf` content block with the document.
## Resources (read-only)
| URI | Content |
|---|---|
| `pdfcreator://themes` | Every theme's name, label, and font family. |
| `pdfcreator://theme/{name}` | One theme's merged palette, styles, header, and footer. |
| `pdfcreator://demos` | Names of the bundled example specs. |
| `pdfcreator://demo/{name}` | A full demo spec (`blank`, `report`, `invoice`, `letter`, `resume`). |
| `pdfcreator://schema` | A concise reference for every spec key and block type. |
## Prompt
| Name | Purpose |
|---|---|
| `make-pdf` | Gives the model the spec contract (top-level keys, block types, units and colors, themes, the `allow_remote` privacy note, the `dependency_missing` hint) and instructs it to compose a spec and call `create_pdf`. |
## The document spec
```json
{
"spec_version": 1,
"meta": { "title": "Field Guide", "author": "Acme" },
"page": { "size": "A4", "orientation": "portrait", "margins": "20mm" },
"theme": "report",
"toc": { "title": "Contents", "depth": 2 },
"content": [
{ "type": "heading", "level": 1, "text": "Field Guide" },
{ "type": "paragraph", "text": "A short **guide**." },
{ "type": "list", "items": ["Alpha", "Beta"] },
{ "type": "callout", "kind": "info", "title": "Note", "text": "Read the schema." }
]
}
```
All top-level keys except `content` are optional. Blocks include `heading`,
`paragraph`, `rich`, `list`, `table`, `image`, `figure`, `code`, `callout`,
`blockquote`, `divider`/`hr`, `spacer`, `page_break`, `page_template`, `columns`,
`toc`, `checkbox_list`, `definition_list`, `key_values`, `anchor`, `qr`, and
`barcode`.
Read `pdfcreator://schema` (or [`references/schema.md`](references/schema.md))
for the full reference, [`references/layout.md`](references/layout.md) for a
worked example of each block, and
[`references/commands.md`](references/commands.md) for the CLI verbs.
## Errors
Tool failures surface as MCP errors whose message starts with the error code:
| Code | Meaning |
|---|---|
| `invalid_spec` | The spec failed validation (not an object, unknown block type, bad field type, …). |
| `dependency_missing` | reportlab is absent; the message includes the install hint. |
| `conflict` | The output file exists and `force` was not set. |
| `render_error` | reportlab raised while building the document. |
| `io_error` / `not_found` | A referenced file could not be read. |
## Configuration
Settings come from the built-in defaults (`scripts/_lib.py`) and optional
`PDF_CREATOR_*` environment variables. They apply only to fields the spec leaves
unset.
| Env var | Default | Env var | Default |
|---|---|---|---|
| `PDF_CREATOR_HOME` | `~/.local/share/pdf-creator` | `PDF_CREATOR_FONT_FAMILY` | `Helvetica` |
| `PDF_CREATOR_PAGE_SIZE` | `A4` | `PDF_CREATOR_FONT_SIZE` | `10.5` |
| `PDF_CREATOR_ORIENTATION` | `portrait` | `PDF_CREATOR_LINE_HEIGHT` | `1.4` |
| `PDF_CREATOR_UNIT` | `mm` | `PDF_CREATOR_TEXT_COLOR` | `#1A1A1A` |
| `PDF_CREATOR_THEME` | `default` | `PDF_CREATOR_ACCENT` | `#2563EB` |
| `PDF_CREATOR_AUTHOR` | *(empty)* | `PDF_CREATOR_OUTPUT_DIR` | `<home>/output` |
| `PDF_CREATOR_LANGUAGE` | `en` | `PDF_CREATOR_ALLOW_REMOTE` | `false` |
The data root holds only generated artifacts: `output/` (the default PDF
destination). `fonts/` and `assets/` are read-only inputs. No database is opened
and no render is recorded.
## How it works
```
spec ──▶ _lib.validate_spec ──▶ _engine.Builder ──▶ reportlab ──▶ PDF
```
The engine resolves the theme, geometry, page templates, and every content
block into a reportlab story, then builds the document (multi-pass when a TOC is
present). The MCP layer reuses those modules unchanged and returns the PDF both
inline (base64 `application/pdf`) and on disk. The database-backed document
library, settings CRUD, asset store, render history, scheduler artifacts, and
the `schedule-hint` verb still exist as CLI code in `scripts/`, but are **not
exposed over MCP**.
## Development
```bash
uv sync
bash tests/smoke.sh # CLI engine end-to-end
uv run python tests/mcp_smoke.py # MCP tools, resources, validation, and render roundtrips
uv run python tests/transport_smoke.py # stdio + streamable HTTP + SSE, end to end
```
`tests/smoke.sh` generates its own fixtures and exercises every CLI verb against
a throwaway data root (render sections self-skip when reportlab is absent).
`tests/mcp_smoke.py` drives the FastMCP server in-process and checks the tool
trio, the resources, validation, error surfacing, Markdown conversion, and an
end-to-end render. `tests/transport_smoke.py` runs the server as a subprocess
and, over each of stdio, streamable HTTP, and SSE, initializes, lists tools,
validates a spec, and renders a PDF.
## Layout
```
apollo-pdf-creator/
├── install.sh # one-time setup (uv sync)
├── pyproject.toml # uv project; runtime deps: mcp, reportlab, Pillow; dev: mcp[cli], pypdf
├── src/apollo_pdf_creator/
│ ├── __init__.py # __version__
│ └── server.py # FastMCP server (tools + resources + prompt + transports)
├── scripts/ # the engine and CLI verbs (reused as-is)
│ ├── _lib.py _engine.py _engine_blocks.py _engine_blocks2.py
│ ├── _markdown.py _rl.py _themes.py templates.py
│ ├── render.py documents.py settings.py assets.py
│ └── reports.py init.py
├── references/ # schema, layout, commands, scheduling
├── tests/
│ ├── smoke.sh # CLI engine end-to-end
│ ├── mcp_smoke.py # MCP-level roundtrips (in-process)
│ └── transport_smoke.py # stdio + HTTP + SSE, end to end
├── .github/workflows/ci.yml
└── LICENSE
```
## License
MIT. See [`LICENSE`](LICENSE).
TDQS
Scored across 3 tools
Each tool serves a distinct purpose: validate_spec checks spec correctness, create_pdf renders a spec to PDF, and from_markdown converts Markdown to PDF. There is no overlap in functionality, making tool selection unambiguous.
Two tools follow a clear verb_noun pattern (validate_spec, create_pdf), but from_markdown deviates by using a prepositional prefix. The inconsistency is minor and does not hinder readability.
With only 3 tools, the server is tightly scoped to its core purpose of PDF creation. Each tool fills a necessary role, and the count falls within the ideal range for a focused server.
The server covers validation, spec-based PDF generation, and Markdown conversion, which are the essential workflows for a PDF creator. A minor gap is the lack of a tool for inspecting or managing generated PDFs, but this does not break core functionality.