This page is for contributors and the curious — how PrintMCP is put together and why. If you just want to use it, the tutorials are the place to start.
PrintMCP is a single MCP server (printmcp) that exposes three groups of tools, one per
stage of the printing pipeline:
flowchart TB
Client(["MCP client<br/>(Claude, …)"]) <-->|stdio| Server
subgraph Server["printmcp server (FastMCP)"]
direction TB
App["app.py · shared FastMCP instance"]
L1["Level 1 · thingiverse.py<br/><code>thingiverse_*</code>"]
L2["Level 2 · cura.py<br/><code>cura_*</code>"]
L3["Level 3 · octoprint.py<br/><code>octoprint_*</code>"]
Config["config.py · env-driven configuration"]
App --- L1 & L2 & L3
Config -.-> L1 & L2 & L3
end
L1 -->|REST API| TV["Thingiverse"]
L2 -->|subprocess| CE["CuraEngine"]
L3 -->|REST API| OP["OctoPrint"]
classDef ext fill:#1f2937,stroke:#9ca3af,color:#e5e7eb;
class TV,CE,OP ext;
A single server means one thing to install, configure, and register with a client — and it lets
an assistant carry context across the whole pipeline (the path it just downloaded flows straight
into slicing, then printing). Tool names are source-prefixed (thingiverse_*, cura_*,
octoprint_*) so groups never collide and new sources can be added cleanly.
Each level reads its own configuration and fails gracefully on its own terms. With only a Thingiverse token you get Level 1; Cura unlocks Level 2; OctoPrint unlocks Level 3. No level imports another's runtime state.
| File | Responsibility |
|---|---|
src/printmcp/app.py |
Creates the shared FastMCP instance. Kept separate to avoid an import cycle. |
src/printmcp/server.py |
Entry point + CLI. Parses --version/--help/--check; with no args, imports the tool modules (registration is an import side effect) and runs mcp.run(). |
src/printmcp/config.py |
All environment-driven configuration: tokens, download dir, Cura discovery, OctoPrint URL/key. |
src/printmcp/thingiverse.py |
Level 1 tools — async httpx calls to the Thingiverse REST API. |
src/printmcp/cura.py |
Level 2 tool — invokes the CuraEngine subprocess off the event loop. |
src/printmcp/octoprint.py |
Level 3 tools — async httpx calls to the OctoPrint REST API, behind the confirm gate. |
src/printmcp/__main__.py |
Enables python -m printmcp. |
tests/ |
Offline tests (input validation + mock-transport HTTP plumbing). |
Tools register themselves via the @mcp.tool(...) decorator at import time. server.py:main()
imports thingiverse, cura, and octoprint for their side effects before calling
mcp.run(). app.py holds the mcp instance so tool modules can import it without depending on
server.py (which would be circular).
main() also handles a few one-shot flags before ever starting the server: --version,
--help, and --check (a per-level configuration self-diagnostic). All CLI output is written to
stderr, because stdout is reserved for the MCP stdio protocol — printing to it in server mode
would corrupt the channel. __version__ is derived from the installed package metadata
(importlib.metadata) so there's a single source of truth with pyproject.toml.
These patterns repeat across all three levels — match them when adding a tool:
- Pydantic v2 input model. Each tool takes a single
paramsmodel withConfigDict(str_strip_whitespace=True, validate_assignment=True, extra="forbid"), typedFields with ranges/constraints, and validators where needed. This gives clients a precise input schema and rejects bad input before any work happens. response_format. Every tool returns either humanmarkdownor machinejson.- Errors as strings. Tools wrap their body in
try/exceptand returnError: <reason>via a per-level_handle_error(...)that maps exceptions (HTTP status codes, timeouts, missing config) to concise, actionable messages — never a raw traceback, never a secret. - Tool annotations.
readOnlyHint,destructiveHint,idempotentHint,openWorldHintdescribe each tool to the client so it can reason about safety.
- Async
httpx.AsyncClient; the token rides in anAuthorization: Bearerheader. - Downloads stream to disk in chunks. Filenames are sanitized (
_safe_filename) and the destination is path-traversal-guarded. - On the cross-host redirect to Thingiverse's CDN, httpx drops the
Authorizationheader automatically, so the token isn't leaked to the file host.
- Wraps the headless CuraEngine binary via
subprocess.run, executed throughanyio.to_thread.run_syncso the blocking call doesn't stall the event loop (this also avoids Windows asyncio-subprocess quirks). - Several engine-correctness details are handled for the caller: pointing the extruder search
path correctly, ordering global
-ssettings before the model (or they'd be treated as per-mesh), supplying CLI defaults Cura 5.11 omits, and parsing real print-time/filament stats from the engine's log (the standalone G-code header only has placeholders). - The subprocess environment is scrubbed of API credentials it doesn't need.
- Async
httpx; the API key rides only in theX-Api-Keyheader to the configured host, read once per request so URL and key can't desync. - The confirm gate is the defining feature: every actuating tool checks
confirmand returns a dry-run preview (via_confirm_required) before constructing any request. See Safety Model. - Response parsing is defensive throughout (missing keys, non-dict shapes,
409when the printer isn't operational) so an unusual printer state yields a friendly message, not a crash.
All tests are offline — no token, network, Cura, or printer required — so they run anywhere in a couple of seconds:
- Input validation & helpers — registration, filename sanitization, slice-input ranges, duration/stats formatting, the Cura version-sort and subprocess-env scrubbing.
- Safety gate — every actuating tool, called with
confirm=false, sends zero requests. - HTTP plumbing —
httpx.MockTransportintercepts requests so tests assert the exact method, path, JSON body, andX-Api-Keyheader of every OctoPrint call, that responses parse to the documented shape, and that error statuses map to friendly strings without leaking the key.
uv run pytest -qDefine a Pydantic input model and an @mcp.tool(...)-decorated async function in the level's
module, following the conventions above. Add offline tests (mock transport for any HTTP).
Create src/printmcp/printables.py with printables_* tools, add config to config.py, and
import it in server.py:main(). The source-prefix convention keeps it conflict-free.
Mirror octoprint.py as moonraker.py with moonraker_* tools and the same confirm gate.
The tool-name prefix is the seam — no shared abstraction is imposed, by design, to avoid
premature coupling between backends.
- Prefer official APIs over scraping. Thingiverse and OctoPrint REST; CuraEngine's CLI.
- Surface licenses. So downloaded models aren't misused.
- Fail readable, never leak. Errors are actionable strings; secrets stay in headers.
- No accidental motion. Physical actions require explicit
confirm=true. - Keep levels independent. Each works on its own; the pipeline is the sum, not a dependency chain.