Skip to main content
Glama

qidi-mcp — MCP server for QIDI Max4 (Klipper / Moonraker)

An MCP server that lets AI agents observe and operate a QIDI Max4 3D printer through Moonraker's API, with a safety layer designed for the fact that the thing on the other end is a 400 °C hotend on a 800 mm/s gantry in a heated chamber.

Status: maintained License: Apache--2.0

Created at Exowatt.

Status: qualified end to end against one physical QIDI Max 4 running the firmware documented below — read-only discovery, a full confirmation round trip, and a complete 27-minute PLA print through the MCP tools. It has not been qualified against any other printer, any other firmware version, or a machine fitted with a QIDI Box filament switcher (those tools are built from QIDI's published macros and a mock only — see the Box guide). Read docs/guides/going-live.md before pointing this at hardware it has not been run against.

"QIDI" and "QIDI Max 4" are trademarks of their respective owner, Qidi Technology. This project is independent, community-maintained tooling and is not affiliated with, endorsed by, or sponsored by Qidi Technology, nor by the Klipper or Moonraker projects.

Try it with no hardware

The whole stack — the MCP tools, the safety gates, the dashboard — runs against a bundled fake Moonraker, so you can see what an agent sees before any printer is involved:

uv sync --extra dev

# terminal 1: a fake printer on loopback, mid-print by default
uv run python scripts/run_mock_printer.py

# terminal 2: point the server at it and talk to it like an agent would
QIDI_PRINTER_URL=http://127.0.0.1:7125 QIDI_TEST_MODE=1 \
    uv run python scripts/validate.py

scripts/validate.py drives real tool calls end to end (read-only mode, confirmation tickets, the safety envelope, capability discovery) and prints what happened. The same mock backs the automated test suite — see Tests — and qidi-dashboard (below) can watch it live.

Related MCP server: prusactl

Install

uv sync --extra dev

(Drop --extra dev for a plain runtime install once you are past trying it out — dev additionally pulls in pytest, aiohttp for the mock, and ruff.)

Configure in your MCP client

Add a qidi entry to your MCP client's server list. Rather than retyping the paths by hand, have them resolved for your checkout:

.venv/bin/python scripts/mcp_entry.py            # real printer, read-only
.venv/bin/python scripts/mcp_entry.py --mock      # the bundled mock, no hardware

mcp-entry.json is the same block as a template with placeholder paths. This is a generic MCP stdio server, so it works the same way in Claude Code, Claude Desktop, or any other MCP client — only the surrounding config file's shape differs:

{
  "mcpServers": {
    "qidi": {
      "type": "stdio",
      "command": "/absolute/path/to/qidi-max4-mcp/.venv/bin/python",
      "args": ["-m", "qidi_mcp.server"],
      "env": {
        "QIDI_PRINTER_URL": "http://192.0.2.10:7125",
        "QIDI_READ_ONLY": "1",
        "QIDI_AUDIT_LOG": "/absolute/path/to/qidi-max4-mcp/audit.jsonl"
      }
    }
  }
}

Environment variables

Variable

Default

Purpose

QIDI_PRINTER_URL

(required)

Printer address; :7125 is appended if no port given

QIDI_API_KEY / QIDI_BEARER_TOKEN

—

Only needed if Moonraker auth is enabled

QIDI_READ_ONLY

0

1 refuses ordinary mutating tools; emergency stop remains callable

QIDI_STRICT_CONFIRM

0

1 requires the two-step token for all guarded tiers

QIDI_ALLOW_DURING_PRINT

0

1 permits motion/thermal changes mid-print

QIDI_ALLOW_DANGEROUS_GCODE

0

Operator switch: 1 lets run_gcode send deny-listed commands, still only with allow_dangerous=true and the token. With 0 they are refused whatever the agent passes. run_macro never overrides the deny-list

QIDI_MAX_EXTRUDER_TEMP

300

Hotend ceiling (hard-capped at the firmware's 375)

QIDI_MAX_BED_TEMP

120

Bed ceiling

QIDI_MAX_CHAMBER_TEMP

65

Chamber ceiling

QIDI_CONFIRM_EXTRUDER_TEMP

260

Above this, extra confirmation

QIDI_MAX_JOG_DISTANCE

100

Per-call jog cap, mm

QIDI_MAX_EXTRUDE_DISTANCE

100

Per-call extrude cap, mm

QIDI_MIN_JOG_Z

0

Z floor for jogs

QIDI_MAX_VELOCITY

800

Ceiling for M203, mm/s (capped at printer.cfg max_velocity)

QIDI_MAX_ACCEL

30000

Ceiling for M201/M204, mm/s² (capped at printer.cfg max_accel)

QIDI_MAX_SQUARE_CORNER_VELOCITY

8

Ceiling for M205, mm/s (capped at printer.cfg)

QIDI_WEBCAM_URL

http://<host>/webcam/

crowsnest/nginx snapshot base, derived from the printer host

QIDI_AUDIT_LOG

—

JSONL audit path

QIDI_LEASE_ENFORCE

1

Refuse mutating calls while another agent holds the printer lease

QIDI_TEST_MODE

0

Refuses any non-loopback host (tests and the mock quickstart)

The full list, including identity/role options for anyone fronting this with their own auth proxy, is in docs/reference/configuration.md.

Why a safety layer at all

Moonraker's API is unauthenticated on the LAN by default (QIDI's stock moonraker.conf trusts every RFC-1918 subnet) and it will happily accept M104 S370, SAVE_CONFIG, or a file delete from anything that can reach port 7125. An LLM with that endpoint and no guardrails is one hallucinated tool call away from a scrapped print, a crashed toolhead, or an overwritten config.

This server therefore treats printer control as a privileged operation. Every check below is enforced in qidi_mcp/limits.py and qidi_mcp/policy.py, and has a regression test in tests/test_safety.py:

Tier

Meaning

Gate

read

pure query

none

low

reversible (lights, beeper, fan, messages)

none

motion

moves an axis or extruder

confirm=true

thermal

changes a heater target

confirm=true + range check

print

starts/stops/alters a job

confirm=true

destructive

deletes files, writes config, restarts, reboots, calibrates

two-step token

emergency

E-stop

never gated

Layered on top of the tiers:

  • Numeric envelope. Temperatures, coordinates and runtime velocity/accel limits are range-checked before a byte leaves the process. The extruder default (300 °C) is below both the hardware's own [extruder] max_temp ceiling (375 °C) and QIDI's own heater_temp_limit macro, an independent clamp that caps extruder 370 °C / bed 120 °C / chamber 65 °C; the bed and chamber defaults match that macro's clamp exactly. All three are intersected at runtime with the limits the printer itself reports, so the stricter of the two wins, and an override can only lower a ceiling, never raise one past the hardware maximum. M201/M203/M204/M205 are held to the printer.cfg values (800 mm/s, 30000 mm/s², square corner 8 mm/s).

  • Interlocks read live state: no jogging while a job is printing, no extrusion below min_extrude_temp, no motion on unhomed axes, nothing at all while Klipper is shut down.

  • G-code screening. run_gcode and run_macro share one screen. Scripts are split on \n only, as Klipper splits them, and any other control character or Unicode line separator is refused (a tab, and a \r ending a line, are allowed). Every extended-command parameter name must be letters, digits and underscores, unpadded — Klipper keeps a quoted key like " HOTEND" verbatim, and QIDI's print-start macros re-render their parameters, so that used to be read back as HOTEND; such a line (or run_macro call) is refused outright instead. A name given more than once is accepted by run_gcode and checked at its last value, the one Klipper uses; run_macro refuses a repeated name. On a letter-and-number command (G27, M600, M104, …) the value itself is read exactly as Klipper keeps it, separators included: it may not contain =, a quote or a space, and an empty value on a checked parameter is refused — G27 P1 Z=20 keeps Z="=20"; Klipper's own handlers reject that, but G27/M600 forward it to PARK, whose \|float reads it as 0, so the toolhead went to Z=0 while the screen had approved Z=20 (M117/M118 are exempt — their text is a message). Write the value straight after the letter (G27 P1 Z20, not Z=20); run_macro does the same (M600 Z20). Heater targets (including QIDI's SET_HEATER_TEMPERATURE_SCALED, the decimal spellings of built-ins QIDI renames such as M104.6245197, and the stock macro parameters that reach a heater — PRINT_START/PRINT_START_SET, CLEAR_NOZZLE, M600 R, EXTRUSION_AND_FLUSH, M4029 S, M603/M604 S), kinematic limits, absolute coordinates (G0–G3, tracked per G90/G91 line so the envelope — including the Z floor — holds until the next G90; PARK/G27/M600/PAUSE always, since they switch to G90 themselves) and G-code offsets (SET_GCODE_OFFSET/M290, capped at ±5 mm) are range-checked. The Box's firmware commands (E_LOAD, MULTI_COLOR_*, EXTRUDER_LOAD, …) have unpublished parameters, so any whose name could hold a temperature is held to the extruder envelope on the load/unload family and refused outright on the rest. These value checks run before the deny-list, so a deny-listed macro's planted temperature or coordinate (PRINT_START_SET, _PRINT_START_PHASE_INIT, M4029, M603/M604, PAUSE/_PARK_INNER) stays bounded even with the operator switch on; a handful of commands that would defeat the value checks themselves (SET_GCODE_VARIABLE, the heater-scaling macros, the deferred-command macros, the wait-wrapper internals) are in ALWAYS_DENIED_GCODE_COMMANDS, which the operator switch can never unlock. SET_FILAMENT_SENSOR ... ENABLE=0 (the primitive behind DISABLE_ALL_SENSOR) is denied; ENABLE=1 is fine. Raw print starts (SDCARD_PRINT_FILE, M23, M24, M32) are denied in favour of start_print/ resume_print, which claim the lease and bind the checked file. A deny-list refuses SAVE_CONFIG, FORCE_MOVE, SET_KINEMATIC_POSITION, TMC current writes, PID_CALIBRATE, every rename_existing target (including its decimal alias), the stock vendor macros that reach those primitives, and friends. Overriding the deny-list needs the operator's QIDI_ALLOW_DANGEROUS_GCODE=1 and the agent's allow_dangerous=true and token; run_macro never overrides it. Macros are screened by name and parameters, not expanded, so a user-defined macro is only as safe as its name and listed parameters — see known limitations.

  • Read-only mode (QIDI_READ_ONLY=1) refuses ordinary mutating tools (emergency stop is exempt) — the safest way to let an agent watch a print it must not touch.

  • A printer lease so two agents cannot drive one machine. Each agent runs its own server process, so the claim is stored on the printer (a namespace in Moonraker's database) — the only state they all share. Holding it refuses another agent's mutating calls by name, and start_print requires claiming the machine first. Cooperative, not a lock: the touchscreen and Fluidd ignore it, and prints started there do not take the lease in this release (the during-print interlock still refuses motion/thermal changes while any job runs, from any agent, regardless of who holds the lease). See sharing the printer.

  • Audit log: every non-read action appended as JSONL with tier, args and outcome.

A tier is not always fixed. Ten tools choose one per call, so the count above is the set that always needs a token, not the set that ever can. set_temperature is ungated at 0 (turning a heater off is safety-positive), ordinary at a normal target, and escalates to a token past the confirm threshold; run_gcode spans three tiers by what the line does; upload_gcode, move_file and copy_file escalate when they would overwrite something.

Tools

101 tools in eleven modules (verified by querying a mock-backed server: 38 read-only, 24 carrying the destructive annotation, none unannotated). R = read query, W = mutation whose confirmation depends on its tier, D = two-step confirm_token. Low-tier actions need no confirmation by default; strict mode escalates the guarded tiers.

Of those 24, 22 always require a token, and the two exceptions are both deliberate. emergency_stop is annotated destructive because it truly is — it kills the print and shuts Klipper down — but it sits in its own never-gated tier, exempt from confirmation, read-only mode and interlocks alike; a safety mechanism you have to argue with is not one. abort_macro is the gentle stop for a runaway macro, so it takes a single confirm=true rather than a round trip. These counts are asserted by tests/test_docs.py and by the safety-invariants CI job, because they have drifted before.

Discovery & status (13, all R) — get_printer_status, get_capabilities, get_temperatures, get_position, get_job_status, get_printer_config, query_objects, list_gcode_commands, get_gcode_log, get_temperature_history, get_system_info, get_audit_log, get_safety_policy

Thermal (3) — set_temperature W, wait_for_temperature R, cooldown W

Motion (8, all W) — home_axes, move_absolute, move_relative, set_z_offset, extrude_filament, set_speed_factor, set_flow_factor, motors_off

Print jobs (11) — start_print D, pause_print W, resume_print W, cancel_print D, get_print_history R, get_history_totals R, get_job_queue R, check_print_fit R, queue_jobs W, remove_queued_jobs D, control_job_queue W

Files (11) — list_gcode_files R, get_file_metadata R, list_file_roots R, list_directory R, download_file R, upload_gcode W, move_file W, copy_file W, create_directory W, delete_file D, delete_directory D

Devices, fans & lights (10) — list_fans R, list_devices R, query_filament_sensor R, set_fan_speed W, set_case_light W, set_output_pin W, set_led W, beep W, display_message W, set_filament_sensor W

Calibration & probing (10) — get_bed_mesh R, query_probe R, query_endstops R, probe_accuracy W, screws_tilt_calculate W, z_tilt_adjust W, set_input_shaper W, bed_mesh_calibrate D, bed_mesh_profile D, input_shaper_calibrate D

Camera (2, both R) — get_webcam_snapshot (returns an actual image for a vision model to inspect), list_webcams

Sharing the printer (5) — get_printer_lease R, acquire_printer_lease W, renew_printer_lease W, release_printer_lease W, break_printer_lease D (force-releasing someone else's claim is audited with your name)

System & escape hatches (12) — get_klipper_log R, abort_heating W, abort_macro W, emergency_stop emergency (never gated), restart_firmware D, restart_klipper_host D, restart_service D, reboot_printer D, shutdown_printer D, run_gcode D (line-screened), run_macro D, resume_after_power_loss D

Three of these exist only because QIDI's Moonraker fork adds them, verified against QIDI_MAX4's own klippy_apis.py. abort_heating (breakheater) and abort_macro (breakmacro) are soft aborts: they escape a stuck heater wait or a runaway macro while leaving the printer alive, where emergency_stop shuts Klipper down and needs a firmware restart to come back. An agent should reach for these first. get_capabilities reports features.soft_abort so it knows whether they are available; on stock Klipper they degrade to "not supported" rather than pretending to work.

QIDI-specific (Max4) (16) — chamber_heater W, set_polar_cooler W, clean_nozzle W, get_box_status R, list_box_filament_options R, set_box_slot_filaments W, load_box_slot D, set_box_option W, get_saved_variables R, get_filament_presets R, get_ai_detection_status R, set_ai_detection W, load_filament D (staged heat/push/purge), unload_filament D, qidi_bed_level_calibrate D (M4029), save_z_offset D

The QIDI Box (four-spool filament switcher) tools — get_box_status, load_box_slot, set_box_option, and cooldown(also_box_heater=true) — were built from QIDI's published macros and a mock. On the one physical qualification run (see Status above), the read-only tools were run against a real Max4 with a Box and worked (status, slot names, filament table, queue previews); nothing has been written to or moved on a real Box. That run also found that this firmware has none of E_LOAD, E_UNLOAD or DISABLE_BOX_HEATER — it has MULTI_COLOR_* commands with unpublished parameters — so load_box_slot refuses on it, and the Box was present but disabled (enable_box = 0). The tools discover the Box at runtime (it must exist as both box_count > 0 and a live box_extras object, as QIDI's own macros require) and say which half is missing. Read The QIDI Box: first contact before writing to a real machine.

Saying what is in each slot. get_box_status returns one readable line per slot (slot 0 (unit 1, position 1): PLA Matte, black (QIDI)). To change them, call set_box_slot_filaments with plain names: assignments=[{"slot": 0, "filament": "PETG Basic", "color": "dark blue"}] (vendor is optional; only the fields you name change). Names are resolved against the printer's own officiall_filas_list.cfg, so the ids written are ones the firmware already knows; list_box_filament_options searches that table. An ambiguous name (ABS) is an error that lists the candidates, a bare material (PETG) resolves to its "Basic" profile and says so, and a colour snaps to the nearest of the printer's 24 swatches and says so. The first call previews exactly what it would write; repeat it with confirm=true to apply, and it reads the result back. It is refused during a print and cannot clear a slot.

Queueing prints. queue_jobs accepts loose names (["benchy", "bracket abs"]), fails the whole call on an unknown or ambiguous one, and its preview shows what each resolved to, the print time, and whether the Box's filament suits the file. check_print_fit does that comparison on its own, and start_print includes it in its confirmation ticket. The comparison is advisory — which slot the printer feeds for which slicer tool is not published, so it checks materials and colours and never blocks. get_job_queue lists the queue in plain lines with total time, and reports whether the queue advances by itself (automatic_transition).

AI failure detection (noodle = spaghetti, foreign = debris on the plate) ships disabled on a stock Max4. Enabling a detector is free; disabling one removes a safety net and needs confirm=true. Klipper does not run the vision model — QIDI's camera service does and pushes verdicts in via SET_AI_DETECT_RESULT, so a count of 0 from a disabled detector means "nothing ever reported", not "the print is fine".

Live dashboard

Everything else here is for an agent to call. This is for you to watch:

QIDI_PRINTER_URL=http://127.0.0.1:7125 uv run qidi-dashboard
# or against the bundled mock from "Try it with no hardware" above, same URL

QIDI_PRINTER_URL is required — the dashboard refuses to start without it (same validation as the server). It opens a browser on 127.0.0.1:8770 and polls every 2s. Two things worth knowing:

  • It cannot move the printer. The dashboard builds the server with read_only forced on regardless of your environment, so it is the same code path an agent uses with the write half bolted shut. Launching it with QIDI_READ_ONLY=0 already exported does not change that.

  • It shows the envelope, not just the readings. Each heater meter draws the temperature, its target, and the ceiling this server enforces — e.g. the nozzle track is captioned "firmware allows 375 °C; this server stops at 300 °C, and asks above 260 °C". The gap is the point of the project, so it is on screen rather than buried in a config file.

It also reports job progress and layer, motion and Z-offset, filament sensor state, and whether the AI failure detectors are armed. Follows light/dark automatically; ?theme=light or ?theme=dark pins it for a screenshot or a shared link. QIDI_DASHBOARD_HOST / QIDI_DASHBOARD_PORT override the bind (loopback by default on purpose — this exposes printer state), and QIDI_DASHBOARD_NO_BROWSER=1 stops it opening a tab.

The dashboard mid-print

Two routes, both GET:

Route

Returns

/

The page. Single file, no external requests, no web fonts.

/api/state

Aggregated JSON — the same shape the page consumes, so curl | jq works if you want printer state in a shell script without writing an MCP client.

The screenshots above are generated from the bundled mock, not captured by hand against a real machine, and the same script asserts the layout does not scroll sideways at three viewports:

.venv/bin/python scripts/screenshots.py --check

Tests

The suite runs entirely against an in-process mock Moonraker (aiohttp) that mimics the Max4's object model, and a conftest guard that blocks any socket to a non-loopback address, so a test run can never touch a real printer.

uv run pytest -q     # 777 tests (901 with the optional upstream sources fetched), a couple of minutes on an idle machine

Coverage is deliberately weighted toward refusals rather than happy paths: every temperature ceiling, every interlock, token replay and cross-tool token reuse, G-code screen bypass attempts, path traversal, read-only mode, and the stdio transport itself. The mock's object model, macro names and limits are transcribed from QIDI's published printer.cfg, so a passing test exercises the shapes the real machine returns. scripts/run_mock_printer.py (above) is the same mock, exposed as a standalone process rather than a test fixture.

Going live

Read-only discovery has been qualified against the physical Max 4; no write has been commanded beyond the single qualification print described under Status. When you're ready:

  1. Start read-only: QIDI_READ_ONLY=1, then get_capabilities and get_printer_status to confirm discovery matches your firmware.

  2. Drop read-only, try low-tier actions (set_case_light, beep).

  3. Then thermal/motion with the printer watched, then a real print.

emergency_stop works in every mode, including read-only. See docs/guides/going-live.md for the full walk-through.

What adversarial review found

The safety layer was audited by independent agents told to break it and to prove every claim by running code. Twelve real bugs came out of it, each fixed with a regression test that fails if the fix is reverted. Every exploit was re-run after the fix to confirm it is actually closed, rather than trusting the diff.

The full findings, the evidence, and the fix for each live in docs/security/ — including the proof scripts, which can be re-run. The most serious nine:

Severity

Bug

HIGH

N1 SAVE_CONFIG bypassed the deny-list: the screen split on whitespace, but Klipper skips a leading line number.

HIGH

run_gcode screened command names but never argument values, so M104 S374 reached the printer from the same server that refused set_temperature(374).

HIGH

Three guards were written if abs(x) > cap, which approves NaN — every comparison against NaN is False.

HIGH

The deny-list only knew upstream's names, missing QIDI's SAVE_CONFIG_QD and RESET_HEATER_SCALING.

HIGH

Interlocks were judged on a 0.75 s cache, so a refused call's retry was authorised against pre-refusal state.

HIGH

Any HTTP 503 was read as "nothing is printing" — but Moonraker returns the same 503 while a print continues.

HIGH

A failed capability discovery was cached 300 s as "no chamber heater", so cooldown left a ~300 W heater on.

HIGH

A start_print token bound only the filename, so the file's contents could change inside the confirmation window.

HIGH

A negative QIDI_MAX_*_TEMP inverted the range, producing a server that could not turn a hot heater off.

A later review, during the open-source export, reproduced five more bypasses with the token redeemed — run_macro never range-checked its parameters, QIDI's SET_HEATER_TEMPERATURE_SCALED and M201/M203/M205 were unchecked, allow_dangerous needed no operator, and fifteen stock vendor macros reached restricted primitives undenied. All are closed and regression-tested; see the security review.

A second review of that same fix found five more ways past it, all closed and regression-tested in tests/test_envelope_bypasses.py (sections 7–9): the raw built-ins QIDI's wrapper macros hide behind rename_existing (M104.6245197 S374) were reachable unchecked; a line separator other than \n (\r, \x0b, \u2028, …) hid a second value on what the screen saw as two harmless lines; several stock macro parameters — not just names — carried a temperature into a heater (M600 R374, EXTRUSION_AND_FLUSH HOTEND=374); M109/M190 forwarded every parameter into an internal wrapper, so M109 S200 R1 M140 set the bed to 200 °C through a parameter named M; and several deferred or unbounded commands (GCODE_ON_PRINT_STATUS, M0/M25/M601, M1002, M900, the closed-loop stepper-current commands, and an unbounded SET_GCODE_OFFSET/ M290) were not covered. L5 section (e) now traces every parameter of every stock macro to the primitive it reaches (78 flows, 0 uncovered); section (f) checks every rename_existing target is denied. See the second review for the full table and fixes.

A third review, with QIDI_ALLOW_DANGEROUS_GCODE both unset and set, found six more ways past the same gates, all closed and regression-tested in tests/test_envelope_bypasses.py (section 10): padded or quoted parameter names (PRINT_START HOTEND=200 " HOTEND"=374 BED=60) that Klipper keeps verbatim and a forwarding macro re-renders as the plain name, reaching M109 S374; E_LOAD SLOT=0 S=374 and the Box's other firmware commands reaching the wire through unpublished parameters; PRINT_START_SET, _PRINT_START_PHASE_INIT, M4029 S, M603/M604 S and PAUSE/_PARK_INNER X/Y/Z staying open to a planted value with the operator switch on, because only the deny-list (which the switch reopens) covered them; SET_FILAMENT_SENSOR ... ENABLE=0 reaching the sensor it disables; raw print starts (SDCARD_PRINT_FILE, M23, M24, M32) skipping start_print's lease and file binding; and the Z floor lapsing for every line after a G91 anywhere in a script, rather than only until the next G90. The fixes: parameter names are now validated against [A-Z0-9_]+; value checks run before the deny-list, and the handful of commands that would defeat them outright (SET_GCODE_VARIABLE, the heater-scaling macros, …) are in ALWAYS_DENIED_GCODE_COMMANDS, which the operator switch can never unlock; and G90/G91 are tracked line by line. See the third review for the full table.

One reviewer drove Klipper's real GCodeDispatch as an oracle across 154,371 generated command spellings and found zero divergences in the resolved command name, which is the kind of evidence worth having on a safety boundary. That oracle was Klipper's klippy/gcode.py (GPLv3). It is not bundled in this Apache-2.0 export. To rerun the standalone differential proofs, first run bash scripts/fetch_upstream_sources.sh, which retrieves a pinned v0.13.0 parser into Git-ignored research/upstream/; see the provenance inventory. The internal record of this result did not pin the precise parser revision used for the historical 154,371-case run. Rerunning L4_fuzz.py against the pinned v0.13.0 fetch checked 320,424 single-line inputs (current deny-list, up from 271,344 before the safety-envelope-bypass fixes) with zero name divergences and zero safety bypasses. The installed package never imports the upstream parser.

The recurring theme is worth stating: each bug was a place where the code guessed instead of knowing. It guessed how Klipper parses a line, guessed that cached state was still true, guessed what a 503 meant, guessed that undiscovered hardware was absent, guessed that a filename identified a file. The fixes all replace a guess with either a real query or an honest "unknown" that fails safe.

Two rules came out of this and are worth keeping:

  1. A screen that parses its input differently from the executor is not a screen. All G-code classification goes through klipper_command_name().

  2. A guarantee that holds only on the paths you remembered to guard is not a guarantee. The raw escape hatch (run_gcode, run_macro) now applies the same temperature, velocity/accel and axis envelope as the dedicated tools to every heater, limit and motion command it can parse, to the decimal spellings of renamed built-ins, and to the stock QIDI macro parameters that reach one — value-checked before the deny-list, so the operator switch cannot reopen a planted value. It does not cover G0–G3 coordinates after a G91 (until the next G90; a script is assumed to start in absolute mode, so a previous script's G91 makes the check stricter, not looser), Box parameters whose names do not look like a temperature, arc centres, G92, print files (the envelope governs this server's raw path, not what a slicer wrote), or macros the stock config does not define; see the residual limits.

Historical firmware research (not a current compatibility survey)

This was the original question, so it is worth stating plainly. The research checkout under research/ was collected in September 2026 and found no open-source OS/firmware replacement supporting the Max4. Firmware projects move, so re-check current releases before acting on the table:

Project

Targets

Max4?

FreeDi

X-Max3, X-Plus3, X-Smart3, Q1 Pro, Plus4

No

FreeQIDI

X-3 series

No

OpenQ1

Q1 Pro only

No

Qidi-Max-4-Optimized

Max4

Macros/config only — not firmware

Kalico ports

Plus4, Q2

No verified Max4 port

The one Max4-specific community project is a Klipper macro and slicer-profile overlay installed over SSH; it does not replace the OS, Klipper, Moonraker, or MCU firmware. So this server targets stock firmware — which is the right call anyway, since it is what the printer runs. The platform facts below come from QIDI's published config at commit 47fc14c (https://github.com/QIDITECH/QIDI_MAX4/tree/47fc14cefe064dbd30b90582429fe650883ae900), not from querying your machine.

Stock platform, for reference: Debian 11 Bullseye, kernel 5.10.160, aarch64, 4× ARM Cortex-A35 on a Rockchip SoC, separate mainboard and toolhead MCUs (the latter on /dev/ttyS4 at 500000 baud). Moonraker reports API version 1.4.0 and its Klipper is closest to upstream v0.13.0 with QIDI patches — the exact strings the bundled mock returns are MOONRAKER_VERSION = "v0.8.0-266-gd5b0e7f" and KLIPPER_VERSION = "v0.13.0-qidi-284-g5d1b0f3f" (tests/mock_moonraker.py); the physical qualification run matched these.

A security note about your printer

Stock Moonraker binds 0.0.0.0:7125 and lists the private LAN ranges as trusted_clients, with no force_logins. Anything on your LAN can control the printer with no authentication — no API key needed, which is why this server does not require one. That is a property of the printer, not of this server, but it is worth knowing: the safety envelope here constrains this client, and does nothing about any other. QIDI's algo_app also exposes an AI detection API on port 9010. crowsnest.conf advertises the camera on 8080, but on the reference Max4 that port is closed — nginx proxies the stream at /webcam/ on :80 instead, which is what QIDI_WEBCAM_URL defaults to (see qidi_mcp/config.py).

Hardware reference (Max4)

Transcribed from QIDITECH/QIDI_MAX4 config/printer.cfg: CoreXY, 385×405×342 mm build volume (position_max 392/410/342), extruder max_temp 375 °C with min_extrude_temp 135, bed max_temp 125, [heater_generic chamber] max_temp 68, 9×9 bed mesh over 15–375 mm, dual Z with z_tilt, closed-loop X/Y, QIDI's probe_air inductive probe, filament switch sensor, and a multi_color_controller (declared on every Max4, with or without a Box; a Box additionally needs box_extras and box_count) for up to four Box units. The firmware's own heater_temp_limit macro caps extruder/bed/chamber at 370/120/65 °C.

Moonraker on stock firmware: port 7125, trusted_clients covering all private subnets, [octoprint_compat], [history], [timelapse] enabled and [update_manager] absent; camera via crowsnest on 8080.

Prerequisites

No proprietary SDK is required or bundled. You need:

  • A QIDI Max 4 (or the bundled mock — see "Try it with no hardware" above) on its stock firmware, reachable over HTTP from wherever this server runs.

  • Python ≥3.11 and uv.

  • Optionally, Google Chrome or Chromium to regenerate the dashboard screenshots (scripts/screenshots.py).

License

Original code is licensed under Apache-2.0. No third-party GPLv3 firmware/configuration, docs or parser is included. NOTICE and the research provenance index explain how to fetch the pinned source material separately for local verification.

Contributing

See CONTRIBUTING. Contributions require DCO sign-off (git commit -s); no CLA is required.

Security

Please report vulnerabilities privately using GitHub private vulnerability reporting. See the security policy; do not open a public issue.

Available Tools

101 tools
abort_heatingA
Idempotent

Abort a heater WAIT that is blocking the queue (QIDI's breakheater). If the printer is stuck in M109/M190/M191 waiting to reach a temperature it will never reach, this releases the wait without an emergency stop — so no firmware restart is needed afterwards. Heaters keep their targets; use cooldown to actually turn them off. QIDI-specific: not available on stock Klipper.

Inputs: no named parameters. Returns: object

abort_macroA
Destructive

Abort a running macro (QIDI's breakmacro). Use it when a macro is stuck or is doing something you want stopped — a levelling routine, a filament change, a purge — without the emergency stop's shutdown and firmware restart. The macro stops wherever it is, so the printer may be left mid-sequence (parked oddly, heaters on, filament part-loaded): check get_printer_status afterwards. QIDI-specific: not available on stock Klipper.

Inputs: confirm. Returns: object

acquire_printer_leaseA

Claim this printer for yourself, so other agents are refused mutating actions while you work. Give a short human reason ('bracket v3, ~40 min') — the point is that whoever gets blocked can see who to talk to. Refused if somebody else holds it. Expires on its own so a forgotten lease cannot strand the machine; renew it with renew_printer_lease for long jobs. This is cooperative: it does not stop the printer's own touchscreen or Fluidd.

Inputs: reason, confirm, minutes. Returns: object

bed_mesh_calibrateA
Destructive

Probe the bed and build a new mesh. Takes several minutes on the Max4's 9x9 grid and moves the toolhead across the whole bed; the bed should be at printing temperature for a meaningful result. The mesh is active immediately but NOT saved to disk — use bed_mesh_profile to save it. Two-step confirmation.

Inputs: confirm, profile, adaptive, confirm_token. Returns: object

bed_mesh_profileA
Destructive

Manage bed mesh profiles: action='load' to apply a saved profile, 'save' to persist the current mesh (this writes printer.cfg and RESTARTS Klipper), 'remove' to delete one, or 'clear' to drop the active mesh. Save/remove are two-step because they rewrite the config.

Inputs: action (required), name, confirm, confirm_token. Returns: object

beepA
Idempotent

Sound the printer's beeper. duration_ms is capped at 5000. Useful to get a human's attention at the machine.

Inputs: repeat, duration_ms. Returns: object

break_printer_leaseA
Destructive

Force-release SOMEBODY ELSE's lease, for when its holder has clearly gone away and the machine is stranded. Two-step confirmation, and the break is recorded in the audit log with your name. Check get_printer_lease and look at the machine first: if a print is actually running, breaking the lease does not stop it, and cancel_print or emergency_stop is what you want. On a manual-print claim (a print started at the touchscreen, shown with manual=true) this takes the print over: the claim becomes yours so you can then pause or cancel it.

Inputs: why, confirm_token. Returns: object

cancel_printA
Destructive

Cancel the running or paused print. This ruins the part — the job cannot be resumed afterwards. Two-step confirmation, and it reports how far along the print was so you can see what you are throwing away.

Inputs: confirm, confirm_token. Returns: object

chamber_heaterA

Control the Max4's actively heated chamber: set a target in °C (max 65 by policy), or 0 to turn it off. The chamber is what makes ABS/ASA/PA printing work on this machine. Also reports the chamber circulation fan and the independent thermal-protection sensor. Requires confirm=true to heat.

Inputs: target, confirm, confirm_token. Returns: object

check_print_fitA
Read-onlyIdempotent

Before printing or queueing: does what the QIDI Box records in its slots suit these files? Compares each file's filament type and colours (from its slicer metadata) with the material and colour recorded per slot, and says which slots could serve each one. Names may be loose ('benchy'); an ambiguous or unknown name is an error that lists candidates. Advisory only: it never blocks, and it does not claim to know which slot the printer feeds for which tool. Returns nothing to compare on a printer with no Box.

Inputs: filenames (required). Returns: object

clean_nozzleA

Run QIDI's nozzle-cleaning routine (CLEAR_NOZZLE): heats up, moves over the purge chute and wipes the nozzle. Worth doing before a critical first layer. Requires confirm=true; refused during a print.

Inputs: confirm, hotend_temp, confirm_token. Returns: object

control_job_queueA

Start or pause the shared job queue. action='start' releases it so queued jobs begin printing as soon as the printer is idle — that needs the printer lease, since it commits the machine. action='pause' holds them, which is always allowed: preventing surprise starts is the safe direction. Requires confirm=true. Neither affects a job that is already printing.

Inputs: action (required), confirm, confirm_token. Returns: object

cooldownA
Idempotent

Turn off every heater (extruder, bed, chamber) in one call. Always permitted — this is the safe direction — and the right thing to call when you are unsure of the machine's state or finishing a session. The QIDI Box's own heater (filament drying) is left alone unless also_box_heater=true, so a drying run is not ended by accident.

Inputs: also_stop_fans, also_box_heater. Returns: object

copy_fileA

Copy a file/directory on the printer. Both paths include the root, e.g. source='gcodes/a.gcode', dest='gcodes/backup/a.gcode'. Requires confirm=true.

Inputs: dest (required), source (required), confirm, confirm_token. Returns: object

create_directoryA

Create a directory on the printer, path including the root, e.g. 'gcodes/projects'. Requires confirm=true.

Inputs: path (required), confirm, confirm_token. Returns: object

delete_directoryA
Destructive

Delete a directory on the printer. Two-step confirmation, and the ticket lists what is inside first. force=true is required to delete a non-empty directory.

Inputs: path (required), force, confirm, confirm_token. Returns: object

delete_fileA
Destructive

Delete a file from the printer. Two-step confirmation. Refuses to delete the file that is currently printing.

Inputs: path (required), root, confirm, confirm_token. Returns: object

display_messageA
Idempotent

Show a short message on the printer's screen (M117) and/or write one to the G-code console (M118), which is where a human watching Fluidd will see it.

Inputs: message (required), to_console. Returns: object

download_fileA
Read-onlyIdempotent

Read a file off the printer as text (truncated to a byte budget so it cannot flood context). Works on any root: 'gcodes' for prints, 'config' for printer.cfg and friends, 'logs' for klippy.log. Use tail=true to read the end.

Inputs: path (required), root, tail, max_bytes. Returns: object

emergency_stopA
DestructiveIdempotent

EMERGENCY STOP. Immediately halts the printer: all heaters off, all motion stopped, Klipper enters a shutdown state (this is M112). Any running print is lost. Never blocked — no confirmation, and it works even in read-only mode. Use it the moment something looks wrong. Recovery afterwards needs restart_firmware.

Inputs: reason. Returns: object

extrude_filamentA

Manually extrude (positive) or retract (negative) filament, in mm. The hotend must already be at or above Klipper's min_extrude_temp (135 °C on the Max4) — heat it with set_temperature first. Capped per call by policy. Requires confirm=true. Blocked during a print.

Inputs: distance (required), confirm, feedrate, confirm_token. Returns: object

get_ai_detection_statusA
Read-onlyIdempotent

Report the Max4's AI failure detection: whether spaghetti ('noodle') and foreign-object detection are enabled, their sensitivity, and the last verdict the camera service reported. Read-only. Note a zero count from a DISABLED detector means 'nothing ever reported', not 'the bed is clear' -- check the enabled flags before trusting it as evidence of anything.

Inputs: no named parameters. Returns: object

get_audit_logA
Read-onlyIdempotent

Every non-read action this MCP server has taken on the printer this session, with risk tier, arguments, and outcome (including refusals). Use it to answer 'what did the agent actually do to my printer?'

Inputs: count. Returns: object

get_bed_meshB
Read-onlyIdempotent

The active bed mesh and saved profiles: the probed Z deviation grid, its range, and the mesh area. Reveals whether the bed is warped and by how much.

Inputs: include_matrix. Returns: object

get_box_statusA
Read-onlyIdempotent

State of QIDI's Box (the multi-filament switcher): whether the firmware will actually use it, each unit's four slots with what the printer believes is in them (filament, colour and vendor decoded to names where QIDI's tables are readable), which slot is synced to the extruder, and the live box_extras object verbatim. If the Box is not working, problems says which of the conditions QIDI's macros test is false. Compare slots with the file's filament_type / filament_colors / referenced_tools from get_file_metadata before printing a multi-colour job. Returns box_count=0 on a machine with no Box.

Inputs: no named parameters. Returns: object

get_capabilitiesA
Read-onlyIdempotent

What this specific printer supports, discovered live: heaters and their real temperature limits, controllable fans, sensors, LEDs, output pins, filament sensors, axis travel, calibration features, QIDI-specific Klipper modules, Moonraker components present, and macro count. Call this before assuming a feature exists — QIDI ship a trimmed Moonraker and an extended Klipper.

Inputs: refresh. Returns: object

get_filament_presetsA
Read-onlyIdempotent

QIDI's built-in filament profiles as configured on this printer, including the recommended drying temperature and time per material from the machine's own drying table. Useful for answering 'how should I dry this spool?'.

Inputs: no named parameters. Returns: object

get_file_metadataA
Read-onlyIdempotent

Full slicer metadata for one G-code file: estimated time, filament length and weight, layer heights, per-object height, nozzle diameter, filament type, first-layer and chamber temperatures, thumbnails present, and the slicer used. Read this before start_print to confirm the file matches the loaded filament.

Inputs: filename (required). Returns: object

get_gcode_logA
Read-onlyIdempotent

Recent G-code console history — the commands sent and Klipper's replies, exactly what the Fluidd console shows. The fastest way to see why something failed or what a macro actually did.

Inputs: count. Returns: object

get_history_totalsA
Read-onlyIdempotent

Lifetime totals for the machine: jobs completed, total print time, longest job, and total filament used.

Inputs: no named parameters. Returns: object

get_job_queueA
Read-onlyIdempotent

The pending job queue: what is lined up to print next and whether the queue is started or paused.

Inputs: no named parameters. Returns: object

get_job_statusA
Read-onlyIdempotent

Detail on the current print job: state, filename, progress by both layer and file position, durations, filament used, estimated remaining time, and the slicer metadata for the running file.

Inputs: no named parameters. Returns: object

get_klipper_logA
Read-onlyIdempotent

Read the tail of klippy.log — the definitive place to find why Klipper shut down, what an MCU error was, or what a failed print reported. Truncated to a byte budget.

Inputs: which, max_bytes. Returns: object

get_positionB
Read-onlyIdempotent

Toolhead position: raw kinematic position, the G-code position (offsets applied), which axes are homed, the active Z offset, and speed/flow factors.

Inputs: no named parameters. Returns: object

get_printer_configA
Read-onlyIdempotent

The printer's live Klipper configuration as Moonraker reports it: either the resolved settings tree (section -> key -> value) or the list of section names. Pass a section name to get just that section — the full tree is large.

Inputs: section, list_sections. Returns: object

get_printer_leaseA
Read-onlyIdempotent

Who is currently driving this printer, if anyone. Returns the holder's name, their stated reason, and how many minutes are left, plus whether it is you. A print started at the touchscreen or from Fluidd shows as a manual-print claim (manual=true) held by nobody. Call this first when a write is refused with 'lease_held', and before starting any long job. Reports coordination as unavailable on a printer whose Moonraker has no database component.

Inputs: no named parameters. Returns: object

get_printer_statusA
Read-onlyIdempotent

Overall printer state: Klipper readiness, print job state and progress, temperatures, position, homed axes, and any active error message. Start here — this is the cheapest complete picture of the machine.

Inputs: no named parameters. Returns: object

get_print_historyA
Read-onlyIdempotent

Past print jobs with status, duration, filament used and timestamps. Useful for 'did the last print succeed?' and for spotting a pattern of failures.

Inputs: limit, start. Returns: object

get_safety_policyA
Read-onlyIdempotent

The safety envelope this MCP server is enforcing: temperature ceilings, confirmation thresholds, jog/extrude caps, read-only and during-print flags, and which risk tiers require confirmation. Read this to understand why a call was refused.

Inputs: no named parameters. Returns: object

get_saved_variablesA
Read-onlyIdempotent

The printer's persistent saved variables (save_variables.cfg): Z offset, power-loss-recovery state, Box slot assignments, feature toggles, and the last printed file. Read-only view of what the firmware remembers across restarts.

Inputs: no named parameters. Returns: object

get_system_infoA
Read-onlyIdempotent

Host and service health: Moonraker/Klipper versions, the printer's OS and CPU, load, memory, uptime, throttle flags (undervoltage), MCU load and communication error counters. Use this when prints fail oddly — an undervoltage flag or MCU bandwidth saturation explains a lot.

Inputs: no named parameters. Returns: object

get_temperature_historyA
Read-onlyIdempotent

Moonraker's cached temperature history (roughly the last 20 minutes, 1 Hz) for every heater and sensor: temperatures, targets, and power. Use this to judge whether a heater is tracking its target or a thermal runaway is developing.

Inputs: last_n, sensor, include_monitors. Returns: object

get_temperaturesA
Read-onlyIdempotent

Current temperature, target, and heater power for every heater, plus all read-only temperature sensors (including the Max4's chamber thermal-protection sensor) and temperature-controlled fans.

Inputs: no named parameters. Returns: object

get_webcam_snapshotA
Read-only

Grab a still image from the printer's camera and return it for inspection. Use this to check on a print: whether the first layer stuck, whether there is spaghetti, whether the part came loose. This printer switches its case light off when the touchscreen sleeps, so an idle machine usually returns a black frame: rather than hand you an image with nothing in it, the tool refuses with error 'frame_too_dark' and tells you to call set_case_light first. Pass allow_dark=true only if you want the raw bytes regardless — never describe the contents of a frame returned that way.

Inputs: allow_dark, camera_url.

home_axesA

Home the printer. Omit axes to home all of them (G28), or pass e.g. 'xy' to home a subset. On the Max4, homing Z runs the inductive probe against the bed. Requires confirm=true. Blocked during a print.

Inputs: axes, confirm, confirm_token. Returns: object

input_shaper_calibrateA
Destructive

Run resonance testing with the toolhead accelerometer and compute optimal input shaper settings (SHAPER_CALIBRATE). The gantry is shaken hard through a frequency sweep — loud, and it must not be interrupted. Results are applied in memory; saving them rewrites printer.cfg. Two-step confirmation. Note: the Max4 also ships QIDI's own M901 macro for this.

Inputs: axis, confirm, confirm_token. Returns: object

list_box_filament_optionsA
Read-onlyIdempotent

Search the filament profiles, colour swatches and vendors the printer itself knows (its officiall_filas_list.cfg). Use it to find the exact name to give set_box_slot_filaments. query filters profiles by words in their name or type ('petg', 'matte', 'cf'); leave it empty for all of them. Colours are the printer's fixed swatches, each with a plain-English name.

Inputs: query. Returns: object

list_devicesA
Read-onlyIdempotent

Everything controllable or readable that is not a heater or an axis: fans, output pins, LEDs, filament sensors, and their current values. A quick inventory before poking at hardware.

Inputs: no named parameters. Returns: object

list_directoryA
Read-onlyIdempotent

List one directory's immediate contents (subdirectories and files) under a root. Use this instead of list_gcode_files when you care about folder structure.

Inputs: path, extended. Returns: object

list_fansA
Read-onlyIdempotent

Every fan on the printer with its current speed and type: the part-cooling fan, the Max4's two auxiliary fans, the chamber circulation fan, and the firmware-managed hotend/board fans (which report but cannot be set).

Inputs: no named parameters. Returns: object

list_file_rootsA
Read-onlyIdempotent

The file roots this printer exposes (gcodes, config, logs, timelapse) with their permissions. Tells you where you may read and write.

Inputs: no named parameters. Returns: object

list_gcode_commandsB
Read-onlyIdempotent

Every G-code command and macro this printer accepts, with descriptions where available. Includes QIDI's custom macros (PRINT_START, M4029 levelling, M603/M604 filament handling, CLEAR_NOZZLE, and so on). Filter with contains.

Inputs: limit, contains. Returns: object

list_gcode_filesA
Read-onlyIdempotent

List G-code files on the printer with size, modified time, and (when scanned) estimated print time and filament use. Filter with contains, sort by name or modified time. This is where start_print's filenames come from.

Inputs: root, limit, sort_by, contains. Returns: object

list_webcamsA
Read-onlyIdempotent

The cameras this printer exposes, with their stream and snapshot URLs. On stock QIDI firmware the camera is a crowsnest ustreamer instance on port 8080.

Inputs: no named parameters. Returns: object

load_box_slotA
Destructive

Feed one Box slot toward the extruder with QIDI's E_LOAD SLOT= S=. Refused unless the printer lists E_LOAD: a real Max4 Box seen so far did not (it has MULTI_COLOR_LOAD, parameters unpublished), so on it this tool says so and sends nothing. slot is 0-based across the whole Box (unit 1 = slots 0-3, unit 2 = 4-7, ...). Refused unless the firmware will actually act on the Box (see get_box_status problems), the slot exists, and no tool change is running; empty-slot and already-loaded conditions are shown on the ticket for you to weigh. Two-step confirmation; refused during a print. Not yet verified against a physical Box: run docs/guides/box-first-contact.md first. To unload, use unload_filament -- QIDI's M603 already calls E_UNLOAD for the extruder.

Inputs: slot (required), confirm, hotend_temp, confirm_token. Returns: object

load_filamentA
Destructive

Run one stage of QIDI's filament load routine (M604). stage='heat' warms up and parks over the purge chute, stage='push' feeds filament in, stage='purge' extrudes until the old colour is flushed. Run them in that order, checking between stages — this mirrors what the printer's own screen does. Two-step confirmation; refused during a print.

Inputs: stage, confirm, hotend_temp, confirm_token. Returns: object

motors_offA

Disable the stepper motors (M84). The toolhead can then be moved by hand, but the printer loses its homed position and must be re-homed. Refused while a print is running — it would abandon the job mid-air.

Inputs: confirm, confirm_token. Returns: object

move_absoluteA

Move the toolhead to absolute coordinates in mm. Supply any of x/y/z; omitted axes hold position. feedrate is mm/min (capped by policy). Axes must be homed and each coordinate must be inside the machine's travel. Requires confirm=true. Blocked during a print.

Inputs: x, y, z, confirm, +2 more. Returns: object

move_fileA

Move or rename a file/directory on the printer. Both paths include the root, e.g. source='gcodes/old.gcode', dest='gcodes/archive/new.gcode'. Requires confirm=true.

Inputs: dest (required), source (required), confirm, confirm_token. Returns: object

move_relativeA

Jog the toolhead by a relative offset in mm (any of dx/dy/dz). The destination is computed and bounds-checked before moving, and a single jog is capped by policy. Requires confirm=true. Blocked during a print.

Inputs: dx, dy, dz, confirm, +2 more. Returns: object

pause_printA

Pause the running print. On the Max4 this runs QIDI's PAUSE macro, which parks the toolhead and retracts. Requires confirm=true and an active print.

Inputs: confirm, confirm_token. Returns: object

probe_accuracyA

Probe the same point repeatedly and report min/max/average/median/stddev. This is the diagnostic for 'is my probe repeatable?' — a stddev above ~0.01 mm means the probe or gantry has a mechanical problem. Moves Z only. Requires confirm=true.

Inputs: confirm, samples, probe_speed, confirm_token. Returns: object

qidi_bed_level_calibrateA
Destructive

Run QIDI's own bed-levelling calibration (M4029) — the routine the printer's touchscreen calls, which heats the bed, probes, and stores the result. This is QIDI's equivalent of bed_mesh_calibrate plus Z-tilt, tuned for this machine. Takes 10+ minutes and normally restarts Klipper at the end. Two-step confirmation.

Inputs: confirm, bed_temp, confirm_token, restart_after. Returns: object

query_endstopsA
Read-onlyIdempotent

Read every endstop's triggered/open state without moving. Use this to diagnose a homing failure — a switch already reading 'TRIGGERED' at rest is the usual culprit.

Inputs: no named parameters. Returns: object

query_filament_sensorC
Read-onlyIdempotent

Whether filament is currently detected, and whether the sensor is enabled.

Inputs: sensor. Returns: object

query_objectsA
Read-onlyIdempotent

Escape hatch for arbitrary Klipper status objects. Pass a list of object names (e.g. ['bed_mesh','probe','mcu']); omit to get the full object list instead. Use get_capabilities first to see what exists.

Inputs: objects. Returns: object

query_probeA
Read-onlyIdempotent

Read the probe's current state (triggered or open) without moving anything. On the Max4 this is QIDI's inductive 'probe_air' sensor.

Inputs: no named parameters. Returns: object

queue_jobsA

Add one or more G-code files to the shared print queue. Names may be loose ('benchy' finds benchy.gcode); an unknown or ambiguous name fails the whole call with the candidates. The first call (no confirm) shows what each name resolved to, its print time, and whether the Box's filament suits it; re-call with confirm=true to queue. This is the cooperative way to share the machine: when somebody else is printing, queue your file so it runs after theirs instead of competing for the printer. Requires confirm=true. If the queue is already 'ready' it auto-starts when the printer goes idle, so in that state this counts as starting a print and also needs the printer lease.

Inputs: filenames (required), reset, confirm, confirm_token. Returns: object

reboot_printerA
Destructive

Reboot the printer's host computer (the whole OS, not just Klipper). Everything goes down for a minute or two. Two-step confirmation; refused during a print.

Inputs: confirm, confirm_token. Returns: object

release_printer_leaseA

Give the printer back when you are finished. Do this rather than letting the lease expire — the next person should not have to wait out your timeout.

Inputs: no named parameters. Returns: object

remove_queued_jobsA
Destructive

Remove jobs from the print queue by job_id, or clear the whole queue with all=true. Requires confirm=true.

Inputs: all, confirm, job_ids, confirm_token. Returns: object

renew_printer_leaseA

Extend your own lease, for a job that is taking longer than planned. Refused if you do not hold it, or if it already lapsed and somebody else took it.

Inputs: minutes. Returns: object

restart_firmwareA
Destructive

FIRMWARE_RESTART: reset the microcontrollers and reload Klipper's config. This is how you recover from a shutdown or an e-stop, and how config changes take effect. Loses homing and any in-memory tuning. Requires confirm=true; refuses while a print is running.

Inputs: confirm, confirm_token. Returns: object

restart_klipper_hostA
Destructive

RESTART: restart the Klipper host process (not the MCUs, not the OS). Reloads config without resetting the microcontrollers — a lighter action than restart_firmware, but it still ends any print. Two-step confirmation.

Inputs: confirm, confirm_token. Returns: object

restart_serviceA
Destructive

Restart, stop, or start a systemd service on the printer (klipper, moonraker, crowsnest, …). Two-step confirmation. Restarting klipper or moonraker will drop this connection briefly and ends any print.

Inputs: service (required), action, confirm, confirm_token. Returns: object

resume_after_power_lossA
Destructive

Resume the print that was interrupted by a power loss (QIDI's power-loss recovery, 断电续打). Moonraker rebuilds recovery G-code from its saved state and STARTS PRINTING from where it stopped. Only meaningful when the printer's saved state says a print was interrupted — check get_saved_variables for 'was_interrupted' first. Two-step confirmation, because it begins a real print. QIDI-specific: not available on stock Klipper.

Inputs: confirm, confirm_token. Returns: object

resume_printB

Resume a paused print. Requires confirm=true and a paused job.

Inputs: confirm, confirm_token. Returns: object

run_gcodeA
Destructive

Run raw G-code on the printer — the escape hatch for anything the dedicated tools do not cover. Every line is screened: pure queries (M105, QUERY_ENDSTOPS, DUMP_TMC…) run with no confirmation, anything that acts needs confirm=true or the two-step token, and commands that bypass safety systems or rewrite config (SAVE_CONFIG, FORCE_MOVE, SET_KINEMATIC_POSITION, SET_TMC_CURRENT, PID_CALIBRATE…) are refused unless allow_dangerous=true AND the operator started the server with QIDI_ALLOW_DANGEROUS_GCODE=1. Heater targets, velocity/accel limits and coordinates are range-checked against the safety envelope. Prefer the dedicated tools — they add interlocks this cannot.

Inputs: script (required), confirm, confirm_token, allow_dangerous. Returns: object

run_macroA
Destructive

Run one of the printer's defined macros by name, with optional parameters. This is how you reach QIDI's own routines (e.g. M4029 bed levelling, CLEAR_NOZZLE, PRINT_START). Use list_gcode_commands to see what exists. Two-step confirmation, because a macro can do anything. The assembled command is screened exactly like run_gcode (deny-list, temperature and velocity/accel envelope); deny-listed macros are always refused here.

Inputs: macro (required), params, confirm, confirm_token. Returns: object

save_z_offsetA
Destructive

Persist the current live Z offset using QIDI's M1002 routine, so it survives a restart. Use set_z_offset to dial it in first, then this to keep it. Two-step confirmation, because it writes persistent state.

Inputs: confirm, confirm_token. Returns: object

screws_tilt_calculateA

Probe the bed's screw positions and report how far, and which way, to turn each levelling knob. The Max4 has four screws. Manual bed levelling aid — the printer only measures; you turn the knobs. Requires confirm=true.

Inputs: confirm, direction, confirm_token. Returns: object

set_ai_detectionA

Enable or disable one of the Max4's AI failure detectors: type 'noodle' (spaghetti) or 'foreign' (debris on the plate), optionally setting sensitivity to LOW/MEDIUM/HIGH. Worth enabling before a long unattended print. Turning a detector ON is free; turning one OFF removes a safety net and needs confirm=true. This only flips the firmware flag -- it cannot start QIDI's camera service, so verify with get_ai_detection_status afterwards.

Inputs: enabled (required), detector (required), confirm, sensitivity. Returns: object

set_box_optionA

Change one Box setting QIDI stores in saved variables: 'enable_box' (the firmware's master switch for Box handling), 'auto_read_rfid' (read the spool tag when a spool is inserted) or 'auto_reload_detect' (on a filament runout, run QIDI's AUTO_RELOAD_FILAMENT -- the Box feeds filament by itself). Needs a detected Box, refused during a print, and always needs confirm=true because there is no free direction: switching auto-reload on makes the machine feed filament unattended, and switching enable_box off makes the firmware ignore the Box. Reads the value back and reports whether it took. Slot contents and box_count cannot be set here.

Inputs: option (required), enabled (required), confirm. Returns: object

set_box_slot_filamentsA

Tell the printer what filament is in each Box slot, in plain words. assignments is a list like [{"slot": 0, "filament": "PLA Matte", "color": "black"}, {"slot": 1, "filament": "PETG", "color": "#1353AB", "vendor": "Generic"}]. slot is 0-based across the Box (unit 1 = slots 0-3). Filament and vendor are names or ids from list_box_filament_options; color is a colour word, #RRGGBB or swatch id, and lands on the nearest of the printer's own swatches. Only the fields you name change; everything else is kept. The first call (no confirm) shows exactly what each name resolved to and what would be written; re-call with confirm=true to write it, and the values are read back. This labels spools for the printer; it moves no filament and does not change whether a slot holds filament. Refused during a print. Not yet verified on a physical Box.

Inputs: assignments (required), confirm. Returns: object

set_case_lightA
Idempotent

Turn the chamber/case light on or off. Handy before taking a webcam snapshot.

Inputs: on (required). Returns: object

set_fan_speedA
Idempotent

Set a fan's speed as a fraction from 0.0 to 1.0. Use fan='part' (or omit) for the part-cooling fan; other names come from list_fans (e.g. 'auxiliary_cooling_fan', 'chamber_circulation_fan'). Firmware-managed fans (hotend, board) cannot be set.

Inputs: speed (required), fan. Returns: object

set_filament_sensorA

Enable or disable filament runout detection. Disabling it removes the printer's protection against printing into thin air after the spool runs out — QIDI's own macros disable it briefly during filament changes. Requires confirm=true to disable; enabling is free.

Inputs: enable (required), sensor, confirm, confirm_token. Returns: object

set_flow_factorA

Set the extrusion flow override (M221) as a percentage, 50–150. Applies live. Use sparingly — flow changes mid-print show up as a visible band.

Inputs: percent (required), confirm, confirm_token. Returns: object

set_input_shaperA

Set input shaper frequency/type directly, without running a calibration sweep. Useful to apply values you already know. Applies in memory only.

Inputs: freq_x, freq_y, confirm, confirm_token, +2 more. Returns: object

set_ledA
Idempotent

Set an RGB(W) LED strip's colour. Components are 0.0–1.0. On the Max4 the strip is 'RGB' (25 LEDs); omit index to set the whole chain.

Inputs: led, red, blue, green, +2 more. Returns: object

set_output_pinA
Idempotent

Set one of the printer's output pins by name and value (0–1). On the Max4 these are 'caselight', 'beeper', and 'Polar_cooler' (the chamber air conditioner). Use list_devices to see what exists.

Inputs: pin (required), value (required). Returns: object

set_polar_coolerB

Turn the Max4's chamber cooler ('Polar_cooler' — the active chamber air-conditioning) on or off. Used for PLA in a warm chamber, where you want the chamber cooler rather than hotter.

Inputs: on (required). Returns: object

set_speed_factorB

Set the print speed override (M220) as a percentage, 10–200. Applies live to a running print. Slowing a print that is having quality trouble is a common fix.

Inputs: percent (required), confirm, confirm_token. Returns: object

set_temperatureA
Idempotent

Set a heater's target temperature. heater is 'extruder', 'bed', or 'chamber'; target is °C (0 turns the heater off). Returns immediately — the printer heats in the background; use wait_for_temperature to block until it arrives. Requires confirm=true. Refused if the target is outside the safety envelope, and while a print is running unless the server allows it.

Inputs: heater (required), target (required), confirm, confirm_token. Returns: object

set_z_offsetA

Adjust the live Z offset (baby-stepping), in mm — positive raises the nozzle. Use mode='adjust' to nudge relative to the current offset, or mode='set' for an absolute value. Applies immediately, including mid-print, which is the usual way to fix first-layer squish. Not persistent across restarts.

Inputs: offset (required), mode, move, confirm, +1 more. Returns: object

shutdown_printerA
Destructive

Shut down the printer's host computer. It will NOT come back until someone power-cycles the machine physically. Two-step confirmation; refused during a print.

Inputs: confirm, confirm_token. Returns: object

start_printA
Destructive

Start printing a G-code file already on the printer (path relative to the gcodes root, e.g. 'benchy.gcode' or 'projects/bracket.gcode'). Two-step confirmation: the first call returns the file's slicer metadata plus a token so you can check you are printing the right thing, and the second call with that token starts it. Refused if a job is already running.

Inputs: filename (required), confirm, confirm_token. Returns: object

unload_filamentA
Destructive

Run QIDI's filament unload routine (M603). Heats the hotend to the given temperature, then retracts and ejects the filament. Give a temperature appropriate to the filament currently loaded (e.g. 250 for PLA, 280 for PETG). Two-step confirmation; refused during a print.

Inputs: confirm, hotend_temp, confirm_token. Returns: object

upload_gcodeA

Upload a G-code file to the printer. Provide the content either as content (plain text) or content_base64 (for binary-safe transfer). Does NOT start a print — call start_print separately, deliberately. Requires confirm=true; set overwrite=true to replace an existing file.

Inputs: filename (required), confirm, content, overwrite, +3 more. Returns: object

wait_for_temperatureA
Read-onlyIdempotent

Poll until a heater reaches its target (within tolerance) or the timeout expires. Does not change any target — set_temperature does that. Safe to call repeatedly; returns the temperature trend so you can tell whether heating is actually progressing.

Inputs: heater (required), target, timeout_s, tolerance. Returns: object

z_tilt_adjustB

Level the Max4's two independent Z steppers against each other (Z_TILT_ADJUST). Probes the configured points and adjusts each Z motor until the gantry is square. Run this if first layers are thicker on one side. Requires confirm=true.

Inputs: confirm, retries, confirm_token, retry_tolerance. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3.4/5.0

    Scored across 101 tools

    Disambiguation3/5

    Most tools have a clear distinct purpose, but several clusters overlap heavily: restart_firmware vs restart_klipper_host vs restart_service vs reboot_printer, bed_mesh_calibrate vs qidi_bed_level_calibrate vs z_tilt_adjust, set_temperature vs chamber_heater vs set_polar_cooler, list_fans vs list_devices, run_gcode vs run_macro. The long descriptions do resolve most of these, but the agent must read carefully and an overlap like cooldown/set_temperature(target=0) invites misselection.

    Naming Consistency4/5

    Nearly everything is snake_case with a verb_noun pattern (get_*, set_*, list_*, start_print, cancel_print), which is highly predictable. Deviations are minor: bare noun/verb names (beep, cooldown, chamber_heater, emergency_stop) and domain-prefixed forms (qidi_bed_level_calibrate, bed_mesh_profile, box_*), but the convention stays readable.

    Tool Count1/5

    101 tools for a single 3D printer is an extreme surface; many operations are splintered into near-duplicate tools (four restart variants, three bed-leveling paths, separate list_fans/list_devices/list_directory/list_gcode_files). This far exceeds what an agent can scan and select from reliably, and a large fraction could be consolidated into parameterised tools.

    Completeness5/5

    Coverage of the printer domain is essentially exhaustive: motion, heaters, files/CRUD, job queue, box/multi-filament, calibration, diagnostics, recovery, leases, audit and safety policy all have dedicated tools, with no obvious dead ends. Minor omissions (e.g. no explicit tool-change or PID tuning exposed) are covered by run_gcode/run_macro escape hatches.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI agents to manage multiple 3D printers via MCP, including monitoring temperatures and progress, uploading and slicing models, controlling prints, and viewing cameras.
      3 npm
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI agents to monitor and control Prusa 3D printers over PrusaLink or Prusa Connect, including status and camera checks, file upload and print control, G-code execution, queue and history access, and responding to printer dialogs.
      1
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables an agent to inspect, slice, upload, start, and monitor jobs on a Moonraker/Klipper 3D printer end to end, using one JSON profile for machine-specific configuration. Built-in safety checks block unsafe starts such as incomplete G-code, nozzle mismatches, hot beds, or running jobs.
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      Enables AI assistants to monitor and control Bambu Lab 3D printers over a local network via MQTT, including real-time status, AMS filament info, print control, and maintenance tracking. It supports guided setup, safety confirmations, read-only mode, and a built-in printer simulator.
      2
      80 npm
      MIT