Metadata-Version: 2.5
Name: mcprusa
Version: 2026.11.2.1
Summary: MCP server for the Prusa Core One, over the on-board PrusaLink API
Author-email: Ryan Malloy <ryan@supported.systems>
License: MIT
Keywords: 3d-printing,fastmcp,mcp,prusa,prusalink
Requires-Python: >=3.11
Requires-Dist: fastmcp<5,>=4.1.0
Requires-Dist: heatshrink2>=0.15
Requires-Dist: httpx[socks]>=0.28
Requires-Dist: pydantic-settings>=2.6
Provides-Extra: analysis
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=7.1; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: zeroconf>=0.147; extra == 'dev'
Provides-Extra: discovery
Requires-Dist: zeroconf>=0.147; extra == 'discovery'
Description-Content-Type: text/markdown

# mcprusa

An MCP server for the Prusa Core One, wrapping the printer's on-board
PrusaLink HTTP API so an LLM session can read printer state, browse the
files on the USB drive, and start or stop a print.

This is a machine that gets hot and commits hours of filament, so the
tool surface is not a flat mirror of the API. Reads are generous. Writes
have friction, and the places where the API will happily mislead you are
handled rather than passed through.

## What it refuses to do

Three of these are the whole reason the server exists.

**It never reports that a print succeeded.** `progress` reaching 100%
says the gcode stream was consumed, not that an object is on the bed. A
print that detached ten minutes in still reports 100%. The server says
"the printer reports 100%" and tells you to look.

**It never trusts `state` to tell it a print is over.** The printer sat
at 100% still reporting `PRINTING` for 41 and 44 measured seconds on two
prints, with the heater targets already at zero and the gantry parked.

This is not one endpoint lagging another. **Both** report `PRINTING`
throughout, because the firmware's `PRINTING` includes the park-and-cool
phase after the last layer. So neither state field means "the gcode has
finished", and `progress` is the only signal that does. Budget a minute.

The two endpoints do not lag each other at all: 850 samples at 1s
resolution found zero disagreements, and both edges of a pause were
single-sample flips. Their state fields are still never compared for
equality, but the reason is read ordering in the *client*, not behaviour
in the printer. Anything that fetches them as two sequential calls, this
server included, can see a transition land between the reads at any
sampling rate.

The control tools poll for the state change rather than waiting a fixed
period, because a fixed wait cannot tell "the printer was slower than I
guessed" apart from "the request did nothing", and would report both as
success.

**It never reports a started print off the start call alone.** Creating a
job returns success and a job id while the printer has set no heater
targets at all and may be waiting on a touchscreen dialog. The server
polls state afterwards and tells you what it actually observed.

**It never guesses which file you meant.** The USB is FAT and the
firmware addresses files by mangled 8.3 short names, which collide across
genuinely different models: the stock drive carries `BENCHY~1.BGC` and
`BENCHY~2.BGC`, plus `KEYCHA~1.BGC` and `KEYCHA~2.BGC`. An ambiguous
name fails with the candidate list rather than picking one. Two files on
that drive are 19 and 22 hours long.

**It never trusts the slicer's exit status.** `slice_model` verifies
that an output file exists and parses before saying anything about it,
because `--merge` produces none while exiting 0 on one machine and
segfaulting on another.

## The startup sequence looks broken and is not

This is the thing most likely to cause damage through good intentions. For
the first five minutes of a print the Core One:

1. Heats the bed while holding the nozzle at 170C, below melting point
2. Actively **cools** the nozzle to 150C with the fan at ~5700, because a
   hot nozzle oozes and corrupts the loadcell bed touch
3. Probes, with `axis_z` going **negative** (-2.4, -0.9, -0.3)
4. Runs the mesh points
5. Reheats to the print temperature and lays layer one

Throughout all of that, `progress` reads `0.00%`. First movement off zero
was measured at 333 seconds. `axis_z` is not a progress proxy either: it
reads 10, then 4, then 40, then negative, then 15, all before any plastic.

A reader handed those raw fields reasonably concludes the printer is
malfunctioning, and the helpful thing to do about a malfunctioning printer
is cancel the job. So `get_status` returns an interpreted **phase**
alongside the raw fields rather than instead of them:

| Phase | What it means |
|---|---|
| `heating_bed` | Bed climbing, nozzle parked at standby |
| `cooling_for_probe` | Nozzle cooling on purpose, fan hard on |
| `probing` | Loadcell finding the bed, Z at and below zero |
| `reheating` | Climbing to print temperature after the cold probe |
| `first_layer` | Laying layer one, part-cooling fan still off |
| `printing` | Past the first layer |
| `attention_before_heating` | A dialog is waiting, no heater set, nothing printed |
| `attention_during_startup` | A dialog during startup, heating, nothing printed |
| `attention_mid_print` | Paused mid-print, a part is on the bed |
| `created_not_started` | A job exists but no heater has been touched |
| `reported_complete_unverified` | 100% consumed, outcome unknown |

Those three attention phases are the same thing as far as the printer
is concerned, and that is the point. A mid-print colour change, a
dialog raised during startup, and one raised before any heater is set
all report `ATTENTION` on `/api/v1/status` and `PAUSED` on
`/api/v1/job`. The state fields cannot separate them, and they are
different situations: one is most of a finished part, the others have
nothing on the bed. What separates them is `progress` and the heater
targets.

That distinction is load-bearing. `cancel_print` is the only remote way
out of any of these states, so a tool that treats them alike will throw
away a nearly-finished print while reporting that it resolved an
attention state. Cancelling a print with any progress therefore
requires `acknowledge_discarding_progress`.

**There were two of these, and the missing third was a real bug.** The
condition read `progress > 0 OR target_nozzle > 0`, and a startup
dialog holds the nozzle at its *probe* temperature, so a prompt raised
before anything printed reported as a colour change "0% of the way
through", asserted a part on the bed, and set `work_at_risk`. Measured
live by a sibling session: `ATTENTION`, progress 0.00, `target_nozzle`
170, an empty bed, and the uneven-bed Z-alignment offer on screen.

`work_at_risk` is the field that mattered. It is what makes a reader
treat a dialog as urgent, and it was wrong in the direction that causes
a hurried decision at the machine. The evidence string gave the fault
away in plain text — it rendered as `progress=0.00 (so the print had
started)`, which disproves its own claim — but a model told to trust
`phase` over the raw numbers would never see it. A diagnostic that is
confidently wrong is worse than one that admits the limit, which is why
these phases now say only what their numbers support and name no cause
PrusaLink cannot see.

**No phase names the dialog**, because PrusaLink reports `ATTENTION`
and nothing about the question. The screen says, and a syslog feed
says: `G29` logs the uneven bed before prompting and `marlin_server`
logs `set_warning(N)` for each warning raised. The lab consumes that
and reports the specific dialog. This server deliberately does not,
because `phase` is pure and synchronous and reaching for a log would
make the same printer state yield different phases depending on whether
a lab happens to be configured. A diagnostic that varies with
configuration is worse than one that is consistently honest about its
limit.

Every threshold in the interpreter comes from a measured trace of a real
print rather than from reading docs, and `tests/test_phases.py` replays
every row of that trace.

**It classifies by relationship, never by absolute temperature**, and
that correction came from being wrong. The 170 and 150 figures were
tabulated from one PLA print and described here as machine constants.
They are filament-profile parameters: PETG probes at **130** and prints
at **255/85** where PLA probes at 150 and prints at 230/60. If the print
target moves with the material, there was never a reason the probe
target would be pinned — and a rule matching `target == 150` misses the
probe phase on every other material. It returned `unrecognised` on a
real PETG print.

**Four separate misreads in one day all came from the same place:**
rules keyed on absolute nozzle temperatures. PLA-only probe detection,
a residual-heat nozzle read as a print starting, a stale elapsed clock
read as a stalled job, and FLEX standby (160) read as reheating because
160 looks like "below the print target and climbing". That was a
sequencing problem, not a thresholds problem.

The fields that are material-independent — bed versus bed target,
nozzle versus nozzle target, `axis_z`, and whether progress has ever
moved — pin every phase observed across three materials.

The one genuine machine constant is the **minimum extrusion
temperature**, which the printer reports in `/api/v1/info` (170 here).
Below it nothing extrudes whatever is loaded, so a target at or under it
belongs to the startup ritual and anything above it is the material's
print temperature. Everything else is read from relationships: the sign
of `temp_nozzle - target_nozzle`, whether `fan_print` is running, whether
the bed has arrived.

That `unrecognised` branch is why the error surfaced at all. A
classifier that forced 130 into a known bucket would have hidden it, and
one that called it a fault would have blamed the printer.

## Two independent sources

The camera is not a printer peripheral. The Core One only supplies it 5V
over USB; it is a separate WiFi device with its own DHCP lease, and
PrusaLink exposes no camera route at all (`/api/v1/cameras`,
`/api/v1/camera`, `/api/v1/cameras/snap` and `/api/cameras` all 404).

| Source | Config | Carries | Auth |
|---|---|---|---|
| PrusaLink | `PRINTER_HOST` | state, files, job control | HTTP Digest |
| Buddy3D camera | `CAMERA_HOST` | stills over HTTP, RTSP as fallback | none |

The camera is optional and absent by default. Its absence is a normal
state the server reports plainly, not a startup failure.

### An HTTP still first, RTSP only as a fallback

With the SD-card overlay the camera serves
`http://<camera>/snapshot.jpg`: unauthenticated, 1080p, typically 600
to 750 KB in well under two seconds. That is the default path and it
is strictly simpler than decoding a frame out of a video stream. No
ffmpeg, no RTSP negotiation, no credentials, and none of the Connect
setup below. `CAMERA_TRANSPORT` is `auto`, `http` or `rtsp`.

**HTTP 200 does not mean the body is whole.** The camera has around
34 MB of RAM and its capture process is killed when several things
want video at once, so a snapshot taken against a running timelapse
can return status 200 and a file that simply stops. Measured: one
fetch stalled past 25 s and returned 100 KB at status 200, while the
retry returned 670 KB in 4.9 s. So every reply is checked for both
JPEG markers, `ffd8` at the start and `ffd9` at the end, and retried
up to three times. A truncated still is reported as itself and does
**not** fall back to RTSP, because the endpoint is present and
struggling and an RTSP error would send someone to the wrong setting.

**Do not poll it.** Four snapshots in quick succession on top of a
timelapse took the camera off the network entirely. Each hit spawns a
shell handler on a device with a few MB of RAM free.

**And the endpoint does not capture on request.** It serves
`/tmp/buddy_snapshot.jpg`, which a background loop rewrites every 5 to
15 seconds, so spacing the *requests* does not space the *frames*: two
reads inside one window return byte-identical data. The default gap is
12 seconds, past the usual period, and that still cannot promise it —
so the frames are compared and the result says plainly when they came
back identical. One frame repeated is not two views and cannot
corroborate itself, which is the exact failure the pair exists to
prevent. A `503` means the camera is up and the grabber has not
written its first file yet, normal for about 20 seconds after boot.

**The camera is not necessarily on the printer's network**, and here
it is not: the printer sits on the managed LAN and the camera on the
ISP wifi. So the camera fetch never uses `PRUSA_PROXY`, and it passes
`trust_env=False` to httpx, which otherwise reads `HTTP_PROXY` and
`ALL_PROXY` from the environment and would quietly route a LAN camera
through the printer's tunnel. That failure looks exactly like the
camera being down.

Connect and connect timeouts are also separated. A TCP connect on a
LAN either succeeds at once or never, so it gets three seconds while
the read keeps the full timeout. Without that split, a camera whose
lease had moved cost three full read timeouts before anything else
was tried; now it fails in about three seconds with a diagnosis.

**Stock firmware, with no overlay, has no such endpoint** and needs
the RTSP route, which takes two steps and the second is easy to miss.
Pair the camera by scanning the QR code Connect generates, then switch
**Streaming** on from Connect's camera page. RTSP is off by default, and
until it is enabled every port on the camera is closed, so a camera that
is paired but not streaming is indistinguishable over the network from
one that is absent. Connect showing live images does not mean streaming
is on: those arrive by cloud upload, not RTSP, and are 10 to 90 seconds
stale by design. A frame that corresponds to *now* has to come from the
local stream.

Because those two causes cannot be told apart from here, a failed
snapshot names both and says which setting to go and check. The HTTP
path can do better, and does: a refused connection on a host that
answers means the camera is up and its software is not, while no
answer at all means the camera is not there. Those have different
fixes, and the error text separates them rather than offering both.
Finding a moved camera means sweeping the subnet *before* looking in
`ip neigh`, because the neighbour table only lists hosts that have
spoken recently.

**Never identify a camera by whether it needs a password.** More than
one Buddy cam can sit on the same network, and whether a stream carries
auth is a configuration choice, not an identity. One observed pair:
`.245` serves `/live` with no auth, `.13` demands digest auth with
realm `LIVE555 Streaming Media` — and LIVE555 is a generic embedded
RTSP stack used by many devices, so the realm names the software, not
the vendor. Both are Prusa cameras.

So the address is **configuration, never discovery**. A scan can land
on the wrong printer's camera and return a confident frame of entirely
the wrong machine, which is worse than no frame because it looks like
success. If the frames show a machine you did not expect, say so.

**ffmpeg cannot use a SOCKS proxy for RTSP.** When the camera is only
reachable through a jump host, the SOCKS forward that serves PrusaLink
will not serve the camera. Use a port forward instead:

```bash
ssh -L 127.0.0.1:18555:<camera-ip>:554 <jump-host>
# then CAMERA_HOST=127.0.0.1:18555
```

TCP transport (which this already uses) keeps all media interleaved on
that single forwarded connection.

**The camera cannot be recovered remotely.** It is paired by QR code to
a specific SSID, not to the printer. Move the printer to a site where
that network does not exist and the camera cannot rejoin, even though
the printer itself will. Getting it back needs a fresh QR for the new
wifi *and* physical access to the camera's reset button. So a tunnel to
a relocated printer buys PrusaLink and nothing visual, and no amount of
configuration here changes that.

**The stream is unauthenticated.** `rtsp://<camera>/live` asks for no
credentials at all, so anyone on the same network can watch the printer.
On a flat ISP wifi, which is where this printer lives, that is everyone
on that wifi. Worth knowing before enabling streaming.

**The frame is evidence, not a verdict, and may not even contain the
subject.** The fixed mounting shows the front half of the bed with the
gantry across the top. During a print the part is often not
identifiable; once the job finishes the gantry parks at Z=225 directly
across the field of view and blocks the bed almost entirely. The camera
has motorised pan and tilt precisely because one fixed view is usually
wrong, and nothing exposes that locally.

So the snapshot tool's description says: describe what is visible, say
that judging print quality needs a person's eyes, and never report that
a print looks fine or has failed. The camera answers "what does it look
like", which is a different question from "did it work", and sometimes
it cannot even answer that one.

**Two frames beat one, and the tool returns two by default.** A single
frame is ambiguous in two ways at once: the toolhead occludes whatever
it is parked over, and a 2mm-tall grey object on a grey sheet has
almost no contrast. A second frame a few seconds later fixes both,
because the head moves and the part does not.

This started as reasoning after misreading an occluded part as an empty
bed, and was then demonstrated on live hardware. Two frames, seven
seconds apart, during a healthy print: the first showed the parts
plainly with clean walls and regular infill, the second had the
toolhead directly over them hiding almost everything. Same printer,
same print, same minute. One frame said healthy, the other said
unreadable.

Seven seconds is about right; much shorter risks both frames catching
the head in the same region on a small part.

**But two frames stop helping the moment a print finishes.** The trick
beats a *moving* obstruction; a parked gantry is a static one. After
`FINISHED` the beam sits straight across the field of view at Z=225.7
and nothing moves, so the second frame is the first frame and neither
shows the bed. Two frames taken seven seconds apart of a finished
plate came back identical and useless, with a sliver of sheet visible
and none of the four parts.

That is exactly when someone asks how it came out, so the server keeps
a frame from while the print was still running:

| | Job object | Bed view |
|---|---|---|
| During the print | available | visible |
| After `FINISHED` | **gone** | **hidden by the gantry** |
| Fix | cache on every status read | cache on every camera read |

Same shape as the job cache, for the same reason: the thing you want
to report on disappears at the moment you want to report it, so it has
to be captured beforehand. `get_camera_snapshot` returns those kept
frames alongside the live ones, each labelled with the progress at the
time, and says plainly when nothing was kept — which means nobody was
looking, not that nothing printed.

**It keeps several frames spread across the print, not the newest
one**, and that distinction is load-bearing. How readable a frame is
depends on where the toolhead happened to be and how the light fell,
and neither tracks progress. On a print sampled at 25, 55, 80 and 95
percent, the 95 percent frame was the worst of the four — head in a
corner, exposure shifted, bed reading as almost empty — while the 80
percent frame showed the part plainly. **Later means more complete,
not more legible**, and legibility is the entire value a camera adds.

There is no rule for picking the readable one; it is a property of the
geometry and the lighting on the day. So the caller gets all of them
and a person chooses. Spreading also degrades better: a print that
fails near the end leaves a newest-wins cache holding a frame of the
failure, with every earlier view of the part building correctly
overwritten.

Three other ways out were considered and rejected for now: aiming the
camera lower is a one-time physical act needing Connect; `MOVE_Z` to
drop the bed exists only in Connect's command set and moves a machine
to take a photograph; and neither is available over the local API.

## A reading taken in the same breath as a change

`start_print` polls until the printer's state moves before
classifying, and that is not sufficient. Observed on a live start: the
state flipped to `PRINTING` at once so the poll returned immediately,
but the **bed target had not been set yet**, so the reading was "a hot
nozzle with no bed target" — accurate for that instant, and carrying a
caution that nothing would progress on its own. Seconds later the bed
was climbing.

The phase was right; the **prediction** was wrong, and a prediction is
the only kind of claim here that can be falsified by what happens
next. So `PhaseReading` marks those structurally (`predicts_stasis`)
and `start_print` drops them, because it knows something no poll does:
it just created a job, which makes a forecast of stasis
self-contradictory.

The direction of the error is why this mattered more than its size. A
caution saying nothing will progress tells a caller the start did not
take, and the obvious response is to start again — which on a machine
that *did* take means a second job on top of a running print.

This is the stale-clock defect with a different field: a value read
alongside the mutation that produced it cannot be trusted about the
state after it.

## Two honest readings that mislead when paired

`get_printer_state` and `get_last_observed_job` are each truthful and
their combination is not. Observed on the real machine: state
`FINISHED` beside a cached `PRINTING, progress 66`, which reads as a
print that died at 66% and actually meant a poll that stopped at 66%,
42 minutes before the end.

So the cached-job tool reads the live state and says what the pair
means:

| Live state | Cached progress | What it means |
|---|---|---|
| `FINISHED` | below 100 | **Stale by construction.** The printer cannot reach FINISHED without passing 100%, so this is the last sample taken, not where the print ended. |
| `IDLE` | below 100 | **Genuinely ambiguous.** A cancel has been seen to leave the printer IDLE, and separately STOPPED, so the figure may be near where it stopped, or where polling stopped. The fields do not separate them, and neither does the tool. |

The first is a certainty rather than a heuristic, which is why it is
worth stating outright. The second is not, which is why it is reported
as an ambiguity rather than resolved.

`observed_seconds_ago` is what makes either diagnosable, so quote it
alongside any cached figure.

## Transport is configured, not assumed

`PRINTER_HOST` says *where* the printer is. `PRUSA_PROXY` says *how* to
get there, and it is optional:

```bash
PRINTER_HOST=192.0.2.10
PRUSA_PROXY=socks5h://127.0.0.1:1080   # empty for a direct connection
```

This exists because within two days this printer was on wifi, then on
ethernet behind a route that could not reach it, then on ethernet
through an SSH forward. Prefer `socks5h://` over `socks5://` so a
hostname resolves at the proxy's end; it makes no difference for a
literal IP, but a name that only resolves on the far side will
otherwise fail locally.

A proxy failure is reported as **a proxy failure**, not as a printer
that is off. Those produce identical silence and have completely
different fixes, and the whole afternoon that produced this note was
spent blaming the second for the first. If the forward dies, the error
says the route failed and that the printer may be perfectly healthy.

### The routing trap worth knowing

A destination can be unreachable while `ip route get` looks fine. A
docker compose network claiming `192.168.0.1/20` silently swallows
everything from `192.168.0.0` to `192.168.15.255`, so a printer
inside that span resolves to a bridge interface that may not even be
up. Longest-prefix match means that bridge wins before any VPN or
policy route is consulted, and a SOCKS proxy inherits the same table.

The check that catches it is reading **which interface** came back, not
whether anything did. A missing route is obvious; a wrong one looks like
a working one.

## Watching an upload somebody else started

`get_upload_transfer` reports a file currently landing on the printer,
and `abort_upload_transfer` stops one. These exist for an upload
started elsewhere, from PrusaSlicer, from Connect, or from another
session, because an upload through this server blocks its own call and
cannot poll itself.

Worth checking before printing or deleting: a file still arriving is
not yet a file you can print, and a just-finished upload can briefly
refuse deletion while the printer indexes it.

The printer answers `204 No Content` when nothing is transferring,
which is a fact rather than an error and is reported as one. It is a
singleton endpoint: no per-id form, and `OPTIONS` is 405.

## Knowing which build you are talking to

`get_server_version` returns this package's version and needs nothing
from the printer, so it answers when the printer does not. The same
value appears on `get_printer_info` and on the unconfigured server's
`check_configuration`.

**If that tool is absent, that is itself the answer.** Dating a server
by which tools it offers works on every build, including ones shipped
before anyone thought about versioning, and needs no cooperation from
the server at all — which a version field cannot, since the fix for
"a client cannot see the version" is itself version-gated and can
never date the one report that most needs dating. The manifest is
coarse, moving only when tools are added or renamed, but dating a
report never needs "which build", only "could this build have
contained the fix".

It exists because an MCP client cannot otherwise see a server's
identity. The protocol handshake carries the *framework's* version,
not this package's, and the startup banner goes to stderr at launch
where a client that attached later never sees it. Without a version
field, every bug report crossing between tools is implicitly dated and
nobody can see the date.

That is not hypothetical: a detailed classifier fault report arrived
describing behaviour fixed three releases earlier, and establishing
that cost an afternoon of re-running traces to disprove a bug that no
longer existed. Quote this field in any report.

## Checking a file before you commit a plate

`check_local_file` runs the same checks as an upload and sends
nothing: material, nozzle and flow read from the file and compared
against the machine, plus whether a thumbnail is present.

**Most files you slice yourself have no thumbnail.** PrusaSlicer's
command line produces none whatever the profile asks for — verified
byte-identical with and without a GL context — so those files answer
404 on the printer's `/thumb/` routes. Measured on a real drive: 21
files carried a thumbnail and 12 did not, and the split was exactly
stock files versus locally sliced ones.

That matters because showing a thumbnail is the usual way to tell
colliding 8.3 names apart, and the files without one are *also* the
files whose names encode nothing. They are simultaneously the most
collision-prone and the least inspectable. For those,
`resolve_print_file(read_file_metadata=true)` is the substitute: it
reads what the file actually is.

## Slicing, where the checks are the point

`slice_model` takes local models and PrusaSlicer `.ini` configs, runs
the real slicer, and then checks what came out. Running the slicer was
never the hard part. Across about fifteen plates in a day, every real
failure was something the slicer did quietly.

So the exit status is treated as a claim rather than evidence:

| Checked | Because |
|---|---|
| An output file exists and is non-empty | `--merge` writes nothing at all with more than one model |
| It parses as a gcode container | A file that cannot be read has not been verified, which is not the same as being fine |
| Its real settings match the machine | The config asking for something is not the file carrying it |
| Whether thumbnails are present | The CLI never writes them, so the plate will be unidentifiable on the drive |
| What lands in holes you name | Support gets packed into bolt slots and nothing warns about it |

Any stale file at the output path is deleted **before** the slicer
runs. Without that, a slicer that writes nothing leaves the previous
run's artefact in place and every check above passes against output
nobody just produced — the one case where doing nothing is
indistinguishable from doing it right.

### One model file per call, because nothing else works

Several model paths are refused, and the reason is worth knowing,
because both of the obvious ways to put parts on one plate fail and
they fail differently.

**With `--merge`** there is no output file at all. How it fails is not
stable: **exit 0 with an empty log** where it was first measured,
**SIGSEGV** when reproduced here the same day on the same models and
configs. Reading the status would have caught one of those and
reported the other as a success returning nothing. Checking whether
the file exists catches both while knowing about neither.

**Without `--merge`** it is worse, because it looks like it worked.
The slicer treats each positional model as its own job writing to the
same `--output`, so the last one overwrites the rest. Two unrelated
STLs produced a file listing exactly one object, byte-identical in
size to slicing that one alone, exit 0, empty `problems`. The
`--export-3mf` path does the same thing and says so out loud if you
are watching: it prints `File exported` once per model, to one
filename.

So build one multi-object 3MF with the parts positioned and slice that
with `dont_arrange`. Also the faster route by a distance: twelve small
parts sliced as twelve jobs cost a five-minute startup each, over an
hour of bed probing before any plastic lands.

That failure is the limit of "verify the artefact". The file was a
perfectly valid plate and every check passed, because the checks ask
whether the output is sound and not whether it is the output you asked
for. Those are different questions, and this one is answered by
comparing against the request.

### The camera's SD card

The overlay keeps snapshots and timelapse frames on a microSD card,
and serves them over HTTP Basic auth. That is a different credential
from the printer's, so reading the card needs `CAMERA_USER` and
`CAMERA_PASSWORD` on top of `CAMERA_HOST`. The live `/snapshot.jpg`
needs none of them, which is why a camera can work perfectly while
the card is unreachable.

`list_camera_media` and the `prusa://camera/media` resource report
what is on it; `get_timelapse_frame` returns one frame as a picture;
`download_timelapse_session` saves a whole session to disk. Deleting
is not exposed.

**A timelapse is frames, not a video.** A session is a directory of
JPEGs, so a four-hour print at a ten-second interval is well over a
thousand files. Nothing here returns them as a group: the download
writes a tar to a path you give it, and the single-frame tool is what
feeds an image into a conversation.

Two awkward properties of this interface shape the tools:

**Nothing lists a directory.** The index gives a session name and a
frame count and no filenames, and the file route needs an exact path.
So `get_timelapse_frame` *reconstructs* `frame_NNNNN.jpg` from the
number, and a miss cannot distinguish "no such frame" from "this
session names its files differently" — it says both. The tar's member
list is the only authoritative listing, which is why downloading a
session reports it.

That matters more than it sounds, because **the two kinds of session
name their files differently**. A print session uses the predictable
`frame_NNNNN.jpg`; a dated capture is named for the wall clock,
measured on a live card as `21-27-02.jpg`, which no index can be
computed from. So the frame tool also takes an exact `filename`, and
for a capture that is the only way in.

Print-session frames are numbered **from zero** (`frame_00000.jpg`),
which this tool got wrong on its first outing: it shipped documented
as 1-based and refused `frame=0`, making the first frame of every
session unreachable and every other number off by one. The filenames
say so plainly and were sitting in a tar listing at the time.

**The index is HTML.** There is no JSON anywhere, so it is parsed,
keyed on the `href` values the server also accepts back rather than
on display text. Per entry, not per page: snapshots carry no frame
count and are listed first, so a parser scanning for a name and then
the next count pairs a snapshot with the first session's number and
shifts every count after it.

An empty result is a real answer, and the tools explain it rather
than implying a fault. Worth knowing that **enabling timelapse in the
camera's web UI is not sufficient**, measured on 2026-10-23 with both
switches already on and the card still empty:

- **Print timelapse reads its setting once, at startup.** The
  launcher starts the listener at boot or not at all, so the toggle
  does nothing until the camera restarts.
- **It also needs the printer to stream metrics to the camera.**
  `M334` pointed at the camera plus `M331 is_printing` and
  `M331 pos_z` is how the camera learns a print began; the listener
  otherwise runs and never fires. The overlay's own guide assumes
  printer and camera share a network, which here they do not.
- **Interval timelapse** needs only the setting, since its loop
  re-reads it, but it lives inside the camera's main process, which
  the camera's log shows exiting with 137 — killed for memory.

### Reading the moves themselves

`analyse_toolpath` decodes a binary file's gcode and counts what is
in it: feature types by path length, travel against extrusion, named
objects from their `M486` markers, arcs, layers and height. Lengths
are **XY path distance in mm**, not volume — for mass, the slicer's
own figure comes from `check_local_file`.

It needs the heatshrink decoder, `heatshrink2`, which is a **required
dependency** and was an optional extra until 2026-11-02.

Making it optional was a mistake worth recording, because the
reasoning was sound and the premise was wrong. The intent was to keep
a server that only drives a printer down to three dependencies, with
the decoder paid for only by people who analyse files. But PrusaSlicer
writes heatshrink-compressed blocks, so **every** real `.bgcode`
needs it: the optional path was not a rare path, it was the only
path, and `analyse_toolpath` failed on every file anyone actually had
until they read an error message and installed something.

The stated cost of requiring it was that a C extension can need a
compiler where no wheel exists. That turned out to be checkable
rather than arguable: it ships 99 wheels, cp39 through cp315
including the free-threaded builds, macOS on both arches, manylinux
x86_64/aarch64/**armv7l**, musllinux and Windows on three arches. A
Raspberry Pi and an Alpine container are both covered. The concern
did not survive contact with the package index.

The `[analysis]` extra still exists as an empty no-op, because the
old error message told people to write `mcprusa[analysis]` in their
MCP registration and removing the name would break exactly those
installs.

**It cross-checks itself against the file's own metadata.** Height
derived from the moves is compared with `max_layer_z`, and the layer
count against height ÷ layer height. Those are parsed by a different
path through the same bytes, so agreement is evidence the decode is
right and a disagreement is evidence it is not. That matters because
a wrong decode does not fail — it produces text that still looks
like gcode, with moves missing, and nothing downstream would
question a tally taken from it.

Two mistakes in this area produce confident wrong answers rather
than errors, and both are guarded:

**MeatPack strips spaces.** A real decoded line reads `G0Z40F10000`,
not `G0 Z40 F10000`. Splitting on whitespace to find the command
yields the whole line, matches nothing, and reports zero of
everything from a file that decoded perfectly — which looks like an
empty file rather than a broken reader.

**An arc is longer than its chord.** Measuring a `G2`/`G3` by the
straight line between its endpoints understates it by about 0.03mm
on the small arcs a slicer emits. Invisible singly; 24.62mm across
the 845 arcs of one real plate, which reads as rounding and is not.
Arc length is computed from the `I`/`J` centre, taking the direction
the G-code asked for rather than the short way round, because an arc
crossing the atan2 seam at pi otherwise comes back halved.

**Start moves are not part height.** One profile parks at Z40 to
heat, so the highest Z commanded is double the part's height and
contradicts the file's own `max_layer_z`. Height is tracked only
while extruding.

### Reading back what a file was sliced with

`read_sliced_config` returns the slicer's own configuration out of a
file — about 368 keys on a real plate, so pass `keys` for the handful
you want. `diff_sliced_configs` compares two files and answers the
question a one-variable test is asking: did this change what I think
it changed, and nothing else.

It reports in four parts, because the differences are four kinds of
thing:

```
source_changed      notes  "sources: <part> md5 a1b2..." -> "... md5 c3d4..."
settings_changed    small_perimeter_speed  170 -> 35
outcomes_changed    estimated printing time  24m 9s -> 25m 22s
provenance_ignored  produced on
```

**Check `produced` first.** The two build times come back with how
far apart they are, because the obvious treatment of them was wrong.
A variant cut on one day, compared against a base file re-sliced in
place since, is two eras rather than one test: both files are
internally consistent, the diff is correct about them, and the
comparison still is not what it looks like. Discarding the
timestamps as meaningless hid exactly that. The gap does not prove a
re-slice; it is the only evidence of one the files carry.

**Source** comes next because it outranks the rest: `notes` carries
source-part identity in some flows and `objects_info` is the plate's
contents, so a difference there means the **model** changed and the
two files are not a controlled comparison of anything.

**Settings** are what somebody chose; two keys here on a
one-variable test means it was not one. **Outcomes** are what the
slicer computed as a consequence — time, filament, height — and are
usually the point of the test rather than noise, which is why they
are shown rather than filtered. **Provenance** is the build
timestamp, which differs on every slice of identical inputs.

The split is structural rather than a hand-kept list. On a real plate
exactly twelve of 368 keys contain a space or a bracket: eleven are
the slicer's output summary and one is the timestamp. Everything else
is snake_case configuration, with two computed exceptions named
explicitly, `max_layer_z` and `objects_info`.

That distinction is sharper than it sounds. `filament_cost` is a
price per kilo somebody configured; `total filament cost` is what
this plate came to. One underscore apart, opposite meanings, and a
diff that lumped them together would report a changed price as a
changed result.

Values are compared as text. "0.3" and "0.30" are the same setting
and different strings, so they show as a difference — visible and
harmless, where deciding they are equal needs a type for every key
and can hide a real one.

### Which object is which

A result lists the objects on the plate:

```json
"objects_on_the_plate": [{"index": 0, "name": "BETA_short"},
                         {"index": 1, "name": "ALPHA_tall"}]
```

`check_local_file` reports the same, and so does
`resolve_print_file(read_file_metadata=true)` for a file already on
the printer, which is the case that matters: the printer's screen and
Prusa Connect both address an object by **index**, and the names exist
only inside the file.

**The index cannot be inferred, only read.** Measured on two
independent files, two objects and five, from different authors and
tools: array position matched the `M486` indices exactly, and matched
neither the order the objects were declared in the source nor their
position on the bed.

The five-object plate shows what that costs. Its objects were named
`00` through `04` and the order was exactly reversed, so `00` was
index **4**. Someone cancelling the part they call `00` and guessing
index 0 removes a different object, irreversibly, with no sign until
the print ends. A name that looks like an index and is not one is the
worst case there is here, and it is not a contrived one.

Worse, a spot check endorses the guess. Reversing an odd-length list
leaves the midpoint in place, so on that plate exactly one object,
`02`, has a name equal to its index. Testing the hypothesis against
the middle of the plate confirms it, while four of the five are
wrong.

### Holes that have to stay empty

A bolt slot, a screw clearance hole, a bore a shaft passes through:
the model has a void there on purpose and nothing in the slicer knows
that. PrusaSlicer has filled an M3 bolt slot solid, and separately all
four NEMA17 screw slots on a bracket with support interface right
against the walls. Both sliced without a warning, because from the
slicer's point of view nothing went wrong.

Pass `functional_voids` and each one is reported as clear or not, with
the extrusion types that landed inside:

```json
[{"name": "M3 bolt slot", "x_min": 110, "x_max": 118,
  "y_min": 94, "y_max": 102, "z_max": 9.0}]
```

Coordinates are bed millimetres after arranging; `z_min`/`z_max` are
optional. Each box is inset by about one extrusion width before
testing, so the perimeter tracing the inside of the hole is not
counted as filling it, and extrusions are sampled along their length,
so a single move laying plastic straight across a small hole is caught
even though both of its endpoints are outside.

**No remedy is suggested**, deliberately. The cradle with the filled
bolt slots wanted `support_material_buildplate_only`. The bracket
could not use that at all, because its motor bore genuinely needs
support under the apex for a centring boss with 0.2 mm clearance.
Same symptom, opposite fixes, so the tool reports and you choose.

The check reads the output's own toolpath, decoding the container.
That matters for more than speed: it used to slice a second ASCII
copy with `--binary-gcode=0`, and a second slice is a **different
file**, since bgcode is not reproducible and nothing promises two
runs lay an identical toolpath. The check was answering about a
plate nobody was going to print.

The re-slice survives as a fallback, because a container can be
unreadable for reasons that are not yours. Taking it adds a warning naming what was read, since
the findings then describe an equivalent plate rather than that one.
Note that the output format is decided by `binary_gcode` in the
profile and **not** by the file's name: a plate you called `.gcode`
is routinely a binary container.

If no route works, the result says the check did not run. It never
comes back as an empty finding list, which would read as every void
being clear — the opposite conclusion.

Decoded gcode has no spaces in it, which is why both readers find a
line's command through one shared helper rather than splitting on
whitespace. A reader that splits sees `G1X12Y11E0.5` as a single
unrecognised word, skips every move, and reports a file full of
plastic as containing none. For the void check that is a pass. It is
the only way this check can lie, so it has one place to live and a
test that feeds it space-stripped gcode on purpose.

### A clean result is a narrow claim

An empty `problems` means the checks ran and agreed. It is not a
prediction that the print will succeed, and the tool says so rather
than reporting success.

## Finding a printer

`discover_printers` reports candidates. It never sets anything: the
address stays configuration, and a person picks, because choosing
automatically is how a tool ends up confidently driving the wrong
machine.

Two methods that fail in opposite situations:

| | Finds | Cannot find |
|---|---|---|
| **mDNS** (`use_mdns`) | a printer on this machine's own segment | anything past a router, tunnel or proxy |
| **Sweep** (`subnet=`) | anything packets can reach, **including through `PRUSA_PROXY`** | nothing much, but it is slower |

mDNS is link-local by design, so it is useless for a printer at
another site — which was every situation this project actually hit.
It also fails locally when multicast leaves by the wrong interface,
common on a host with many bridges. The sweep is the one that works
through a tunnel, and it needs no extra dependency; mDNS needs
`mcprusa[discovery]`.

**Identification is positive.** A host counts only if it answers with
an HTTP Digest challenge whose realm is `Printer API`, and with
credentials it is confirmed further by serial and hostname.
`confirmed: false` means a real PrusaLink printer that is not
demonstrably yours, and that distinction is reported rather than
smoothed over.

Nothing is identified by service name, open port, or whether it wanted
a password. Auth is a configuration choice, not an identity: a nearby
camera answering `Digest realm="LIVE555 Streaming Media"` is a real
device that a looser rule would happily have matched.

## One check PrusaLink cannot make: nozzle flow rating

`/api/v1/info` reports `nozzle_diameter` and `mmu` and nothing else
about the tool. Prusa's cloud carries the full record
(`{"nozzle_diameter": 0.4, "high_flow": true, "hardened": false}`), but
the local API does not expose `high_flow` at all, and the slicer does
not put it in the file name either.

That gap has teeth. A file sliced for standard flow on a high-flow
nozzle stops the printer with a touchscreen dialog, and nothing in
PrusaLink can answer a dialog. On a printer nobody is standing next to,
that is a stop with no remote remedy.

So `NOZZLE_HIGH_FLOW` is configuration. Set it and `start_print`
compares it against the file's own `nozzle_high_flow`, read from the
file rather than guessed, and refuses a real mismatch.
Leave it unset and `get_printer_info` reports the rating as unknown
rather than letting silence read as safe.

### Resolving reads names; reading the file is opt-in

`resolve_print_file` reports from the **file name**, so its values are
null for any file not following the slicer's convention. Pass
`read_file_metadata=true` to read them from inside the file instead.

Off by default because the cost is asymmetric: browsing twenty files
should not trigger twenty downloads, while committing an hour of
filament should pay for certainty. Measured on a real file whose name
carries nothing:

```
read_file_metadata=False  ->  material=None  nozzle=None  high_flow=None
read_file_metadata=True   ->  material=FLEX  nozzle=0.4   high_flow=True
```

**Reach for it when a resolve comes back ambiguous.** Colliding 8.3
names are precisely where a filename cannot help, and the error then
describes what each candidate actually is.

`read_from` reports the source **per field**, not once for the whole
result, because a single result legitimately mixes them: the file
wins wherever it has an answer, and anything it does not state still
falls back to the name.

That distinction was not cosmetic. `read_from` used to be one label
reading "the file's own metadata" while `layer_mm` and
`estimated_duration` were still coming from the filename, so both
were null for any file not named by the slicer's convention even
though both values sat in its metadata blocks. The giveaway was the
value itself: a file whose `PrintMetadata` says `59m 17s` reported
`59m`, which is the name's rounded token. Reported from a live drive,
and the label sent the reporter looking in PrusaLink first, which
supplies none of these fields.

Both now come from the file, which is both more precise and populated
where the name is empty:

```
DREMEL~3.BGC  named by hand     layer 0.2  duration 4h 42m 32s   (was null, null)
DREMEL~4.BGC  named by PrusaSlicer  layer 0.2  duration 59m 17s  (was 0.2, "59m")
```

The failure landed exactly where it hurt most. Files named by a
headless slice are the ones carrying no information in their names
*and* no thumbnails, so they are simultaneously the most
collision-prone and the least inspectable, and the two fields that
would cheapest tell them apart were the two still being read from the
name.

### Both paths read the file, because the answer is inside it

`upload_print_file` has the file locally. `start_print` fetches the
first 512KB from the printer, which is enough: the container puts every
metadata block before the gcode block, so on a measured 358KB file all
of it sat within the first 176KB, almost all of that five thumbnails.
The printer ignores `Range` headers, so the read is streamed and
stopped early instead.

That matters because the filename is often empty of information. Three
files on this drive report nothing from their names and this from their
bytes:

| File name says | File says |
|---|---|
| `material=None nozzle=None` | `PETG 0.4 high_flow=True` |
| `material=None nozzle=None` | `FLEX 0.4 high_flow=True` |
| `material=None nozzle=None` | `FLEX 0.4 high_flow=True` |

And flow rating is never in a filename under any convention, while
being the one a mismatch stops the printer with a dialog over. So what
used to be an unverifiable warning is now a real comparison. Reading
the file falls back to the filename if the download fails, and the
result says which source answered. So uploads get
a genuine comparison of flow rating, filament type and nozzle diameter
against the machine, and a real disagreement refuses while quoting both
values.

That is a different kind of refusal from one invented out of missing
information, which is why `acknowledge_preflight_failure` **cannot**
override it. The flag exists for a file whose metadata could not be
read. A measured contradiction is not a judgement call to wave away,
and a refusal people learn to click through protects nothing.

The container is parsed from Prusa's `libbgcode` specification rather
than scanned for strings, because bgcode permits compressed metadata
blocks and a string scan finds nothing in those — then reports no
mismatch, which reads as a pass. Heatshrink blocks need the optional
decoder and without it are reported as unreadable, which is
deliberately its own outcome. Unreadable metadata still uploads, since storing a file is
harmless; it blocks `start_printing` until someone acknowledges it.

## Prusa Connect: a second route, for sending files only

`send_file_via_connect` pushes a gcode file to the printer through
Prusa's cloud. The printer reaches Connect outbound, so this works
without a tunnel, without being on the printer's network, and across
the printer moving networks entirely. It runs the same file preflight
as a direct upload, degrading the checks that need the printer when it
cannot be reached. **Verified end to end** against the real service.

Two measured behaviours worth knowing.

**A 503 here is probably not an outage.** The endpoint answers 503
"Service is unavailable at this moment" to a POST it cannot parse, and
a real 400 to a well-formed multipart with a bad extension. So the
wording misleads: treat it as a rejected upload rather than a reason
to retry unchanged.

**`start_printing` queues; it does not start.** Connect replies
"added into print queue on position #1", the job appears in Connect's
web interface, and the printer stays idle with both heaters at zero
and no job of its own. Something still has to release it. That differs
from the direct upload path, which **acts at once**: measured, it
creates a job within seconds of the transfer finishing. So the same
parameter name means two different things on the two routes, and only
one of them reaches for the machine.

Both were verified rather than assumed, and the second only after the
first turned out to contradict its own documentation. Creating a job
is still not printing: the local run went straight to `ATTENTION` with
a dialog about the gcode, cold and with nothing heated, which is why
the result reports the state it saw rather than claiming a print
began.

**An uploaded file stays locked for minutes.** Connect accepts the
file and the printer then pulls it down asynchronously; during that
window `DELETE` is refused with `409 File is being transferred`,
measured at three to four minutes even for a 179-byte file, so the
duration tracks the pull rather than the size. `delete_print_file`
retries through about two minutes of it and then says to wait rather
than blocking indefinitely.

And `/api/v1/transfer` is **blind to that pull**: it reports nothing
transferring while a delete of the same file is refused because
something is. Two endpoints on one printer disagreeing about whether a
transfer exists, so `transfer_in_progress: false` does not prove the
drive is settled.

**A 409 means something holds the file, which is not the same as a
transfer running** — and that distinction cost a sibling session real
time, because this project got the guidance wrong twice in opposite
directions.

The message first said "wait and try again; this is not a problem with
the file", which a 42-byte upload refused hours later made false. So a
second case was added, with a *test* for choosing between them: an idle
printer reporting no transfer meant the unrecoverable one, and further
retries would not help. That test does not test what it claimed.

The cause nobody had found is **invisible to the API**. A job-done or
cancelled dialog left up on the touchscreen holds the file it just ran,
while the printer reports idle or stopped and no transfer. Measured: a
delete refused repeatedly, then succeeded first try the moment someone
selected "home" on that screen. A session followed the rule faithfully,
concluded the file had to be deleted at the machine, told a person so,
and worked around a lock that cleared on its own.

So the message now orders the responses cheapest-first — look for an
undismissed dialog, then wait and retry, and only then conclude a lock
that will not clear — and offers no way to pick a branch from readings
that cannot distinguish them. **Dismissing a dialog and deleting a file
at the machine are different instructions**, and sending someone to do
the destructive one when the harmless one suffices is the part that
most needed fixing.

The general lesson is the one worth keeping: a guard may name the cases
it knows without claiming a test that separates them. An absent test
leaves a reader looking; a wrong one stops them.

**Uploading over a name already on the drive answered 500.** Observed
once, with `Overwrite: ?1` sent — which this client sends by default,
so a 500 here is *not* the printer refusing to replace an existing
file. A delete of the existing file then returned 409 for a while, and
renaming the upload worked. Why is not established: one session
attributed it to a name collision and then withdrew that, so this
records the observation and not either theory. Practically, a 500 on
upload is worth answering by renaming before reaching for delete,
which is both cheaper and reversible. With 131 files on that drive,
8.3 short-name collisions are routine rather than exotic.

**It cannot do anything else, and that is a property of the credential
rather than a missing feature.** `PRUSACONNECT_API_KEY` identifies *the
printer* to Connect, not the account holder: its last eight characters
are the first segment of the printer's UUID, and it is what the machine
presents when pushing telemetry up. Measured:

| Route | Bad key | This key |
|---|---|---|
| `POST /api/files/local` (upload) | 401 | accepted |
| `/app/printers`, mobile API, state, commands | 401 | **401** |

State, job data, temperatures and the command endpoints need a user
credential. There isn't one a server can hold — see below.

### Everything the printer token reaches, measured

Probed with a bad-key control throughout, because a status code alone
cannot tell a real endpoint from the single-page app, which serves
anyone:

| Route | Verdict |
|---|---|
| `POST /api/files/local` | **real, authenticated** — upload, the one capability |
| `GET /api/version` | real, authenticated — a banner, no printer data |
| `GET /info` | **public**, works with no key at all, so not a capability |
| `/api/files`, `/api/job`, `/api/printer`, `/api/connection`, `/api/settings` | SPA fallback: a bad key gets the same 200 |
| `POST` to any of those, `/api/printer/command`, `/api/login` | 405 for every key |

So the OctoPrint-compatible shim here is an upload chute and nothing
more. There is no job control, no telemetry, no gcode execution.

The upload accepts OctoPrint's `print=true`, which this exposes as
`start_printing`. That flag is **implemented but unverified**:
confirming it means committing a physical machine to a print. The
upload itself is verified end to end.

## Firmware: the same chute, and the one upload worth refusing

`upload_firmware` sends a `.bbf` through that same Connect endpoint,
because there is no other route: PrusaLink's own upload answers **415**
to a `.bbf`. Connect answers **200**, the image lands on the printer's
USB drive, and the extension filter that rejects an unknown suffix on
that endpoint does not reject this one. All three of those were
measured.

**It uploads and does not flash**, and the result says so in three
places rather than one. The firmware is unchanged when this returns;
Connect's FLASH action or the touchscreen applies it. A tool that
could leave someone believing a printer had been updated when it had
not is worse than no tool.

**The copy on the printer cannot be checked.** PrusaLink answers a
download of a `.bbf` with a short error body rather than the file, so
there is no read-back to compare against. The hash returned is of the
bytes that left this machine, labelled as exactly that. If the
transfer matters, read the drive in a card reader.

**There is no magic number in a `.bbf`.** Established by reading real
ones, not from a specification: two official COREONE builds open with
64 bytes of high-entropy signature sharing no common prefix, and no
ASCII marker appears in the first kilobyte. So the checks are
deliberately negative, catching the wrong file rather than confirming
the right one: a renamed gcode, an unextracted archive, a saved error
page, anything under 256 KB. Authenticity is the printer's job, which
it does at flash time, and refusing on a guess would only reject
firmware a printer would have accepted.

A local build opens with 64 **zero** bytes where the official ones
carry signature, which reads as unsigned. That is reported as a note
and does not block, because an unsigned image flashes on a printer
whose appendix seal is already broken. Whether it flashes is a
property of the machine, not of the file, so refusing here would
block something that works. The note says the evidence is one local
build against two official ones rather than implying a format rule.

It refuses while a print is running or paused, since the image lands
on the drive the job is being streamed from. An **unreachable**
printer does not refuse: reaching the machine when the LAN cannot is
the entire point of this route, so that check is allowed not to run
and says which question it left unanswered. And `print_after` is not
a parameter. A firmware image is not a thing to print, and the flag
exists on the shared upload path.

## Why state and control stay on PrusaLink

Connect is Prusa's cloud. Its API is **more** capable than the local
one, not less, so the reason state and control stay local is neither
data quality nor capability: it is that **there is no credential a
server can hold. Only a person who is logged in.**

Its telemetry is not stale: it arrives over MQTT and tracks
continuously. And `/app/printers/{uuid}/supported-commands` returns 46
commands with their arguments and the printer states each is legal in,
including several this server has to tell you are impossible:

- `DIALOG_ACTION` answers a touchscreen dialog remotely, which is
  exactly what PrusaLink refuses with a 409
- `LOAD_FILAMENT` / `UNLOAD_FILAMENT`, which PrusaLink cannot do at all
- `MOVE`, `HOME`, `MESH_BED_LEVELING`, `CANCEL_OBJECT`, `SET_SPEED`,
  `SET_FLOW`, temperature control, even firmware flash

Against that: there is no official third-party API. The only documented
one is the Camera API, for pushing snapshots *in*. The rest are private
frontend endpoints that take cookie auth rather than a token, reached
through an unofficial OAuth2 refresh against `account.prusa3d.com` that
Prusa can change without notice, and reportedly will not accept
`X-Api-Key` or `Authorization` at all.

That last point was established rather than assumed, and the mobile
API's own specification closes it: **every** one of its 42 endpoints
requires `client_jwt_token`, including `/api/v1/init` and
`printers/register`. There is no unauthenticated bootstrap anywhere in
the spec, so the JWT cannot be obtained from the API at all; it comes
from a browser login. The one session that reached those endpoints did
it by running `fetch(..., {credentials: 'include'})` inside a logged-in
browser, so the browser attached a human's session itself. That session died with the browser about two
hours later, with a console line about a refresh timer that lives in
the page. The mobile API's own spec lists a `server_to_server` security
scheme in its global security block and then **never defines it** in
`securitySchemes`.

So a Connect backend for state would be a reverse-engineered transport
that works until a person's browser session ends, then silently 401s,
while controlling a machine that gets hot. PrusaLink is documented,
stable, authenticated and local. If the printer is unroutable, an SSH
forward to a host that can see it keeps the transport honest and
changes nothing above it.

Worth knowing rather than forgetting, though: the ATTENTION state this
server reports as needing a person *is* remotely answerable, just not by
anything here.

### Why not the official Connect SDK

[`prusa3d/Prusa-Connect-SDK-Printer`](https://github.com/prusa3d/Prusa-Connect-SDK-Printer)
is the obvious thing to reach for and is the wrong side of the
relationship. It implements the **printer**: `register()` to enrol a
device with Connect, `loop()` to hold the connection open,
`telemetry()` at least once a second, and `handler()` to decorate the
functions that run when Connect sends a command. Adopting it would not
give this server control of a printer, it would make this server
pretend to be one.

It does corroborate why the printer-scoped key is upload-only, though,
and from Prusa's own code rather than by inference: commands travel
Connect to printer over the printer's outbound connection, and the
credential that receives them belongs to the device.

**Do not use its enumerations as the reference for what a Core One
accepts.** Its `Command` enum has 27 members and includes neither
`CANCEL_OBJECT` nor `UNCANCEL_OBJECT`, while the Core One's firmware
implements both in `src/connect/command.cpp`. Its `PrinterType` lists
`I3MK25` through `HT90` and does not mention the Core One at all. This
SDK and the Buddy firmware are two separate implementations of the
same protocol with diverged command sets, and ours is the one in C++.
Reading the enum would give a confidently wrong answer in both
directions: missing a command the printer has, and listing several it
may not.

Where it would be the right tool is the inverse job. If you ever want
to present a **virtual printer** to Connect, to test against the real
cloud without the physical machine, that is exactly what it is for and
hand-rolling it would be a mistake.

### Reading the metrics lab

`LAB_BASE_URL` points at the lab, a separate service that records what
the printer reports while a calibration run executes. `list_lab_runs`,
`get_lab_run` and `compare_lab_runs` read it. Unset by default, and
the printer tools do not depend on it.

It is separate for the reasons in the next section, not by preference:
a run holds a metrics stream open for minutes while nobody is talking,
and a stdio MCP server dies with its client.

**The lab is multi-printer, and the pairing is checked by serial.**
Each mcprusa talks to one printer over PrusaLink and one lab over
HTTP, and nothing in either configuration makes them the same machine.
With two labs and two printers that is a real hazard: a mismatched
pair would start a sweep on a printer other than the one this server
reports on, and every reading afterwards would look normal.

So `plan_lab_run`, `upload_lab_run` and `start_lab_run` compare the
lab printer's serial against this printer's own `/api/v1/info` serial
and **refuse on a mismatch**, quoting both in full. Checked by serial
rather than by hostname or printer id, because a wrong
`LAB_PRINTER_ID` gives a 404 and is obvious while a wrong
`LAB_BASE_URL` gives a perfectly healthy lab for somebody else's
machine. `start_lab_run` checks before the dry run too: a
confirmation prompt describing a sweep on the wrong machine is worse
than no prompt, since the person answering it cannot see the pairing.

The two serials in play share sixteen identical leading characters of
twenty-two, differing only in the middle (shown here as
`00000-0000000000608922` against `00000-0000000000411652`; the real
ones have the same shape). Printing both and trusting the reader to spot it is how a
mismatch gets waved through, so the refusal aligns them and points at
the character that differs.

An identity that **cannot** be established refuses as well, in both
directions: the lab not knowing its printer's serial, and this
server's own PrusaLink being unreachable. Proceeding on an unverified
identity is the same bet as proceeding on a wrong one, taken with
less information. `preview_lab_run` is the exception: it writes
nothing, so it reports the mismatch instead of refusing, which makes
it the cheapest place to find out.

`LAB_PRINTER_ID` is optional. Unset means the lab's default printer,
which its unscoped routes already mean, and the results say which one
that resolved to rather than leaving it to be assumed. Set, the
per-printer routes under `/api/printers/{id}/...` are used instead. A
lab with several printers and no default is refused as ambiguous
rather than guessed at.

`list_lab_experiments`, `preview_lab_run`, `plan_lab_run`,
`upload_lab_run` and `start_lab_run` drive it. **The lab owns every
rule about when a run may begin**, and this client stays thin enough
that it cannot disagree with them: it refuses nothing on its own
judgement about printer state, it asks and passes the lab's reason
back. Two places deciding that is how they come to disagree.

The steps are separate because they commit different amounts.
`preview_lab_run` **writes nothing** — it answers how long a sweep
takes, how much filament it burns, which K values it visits and what
the lab warns about the combination, leaving no row behind. On the
first real use it caught a proposed sweep whose top end put the
extruder over its E limit, which is exactly what a dry run is for.
`plan_lab_run` then creates the run and its gcode without touching
the printer, and `upload_lab_run` sends it with no print-after-upload.

`start_lab_run` is the one that commits the machine, and **`confirm`
must be true**. Called without it, it reports what would happen and
starts nothing, which is also where it checks the run is actually
startable rather than leaving the lab to answer 409 after someone has
decided to go ahead. The confirmation lives in the argument shape for
the same reason `start_print`'s does: it works on every client rather
than only the ones implementing elicitation.

It returns **the bed as the camera sees it**, with the verdict first
and the picture second, because a lab run extrudes onto nothing and
anything already on the sheet is in the way. The frame's age comes
with it, and a frame the lab has been serving unchanged for minutes
is labelled stale. That matters more than a camera failing: a missing
frame is obvious, and an old frame read as current is someone
concluding the bed is clear when it is not.

**There is no stop**, because the lab has no cancel endpoint by
design. Stopping is `cancel_print`, the printer's screen, or Connect.
`abandon` only marks a database row and does not touch the machine, so
it is not wrapped as though it stopped anything. Re-analysis is not
wrapped either: it performs a sequenced amend on the lab's side, and a
tool that triggers one would be creating beliefs rather than reading
them.

One failure is reported differently from the rest. A **timeout on a
write** says nothing about whether the lab acted, so it never reports
that nothing happened; it says the request may have been carried out
and to read the run's status before retrying.

Two things about this data shape the tools more than the endpoints do.

**It is bitemporal, and that is not flattened.** Every record carries
when it was true (`valid_during`) and when the lab believed it
(`recorded_during`). Re-analysing a run *adds* an entry and closes the
previous one's belief window rather than overwriting it, so several
analyses of one run are not duplicates. Each is returned with its
`estimator_version` and whether it is current, and a superseded
conclusion is never handed back as the answer. On real data the
superseded entry and the current one differed by a factor of six, so
a reader ignoring the belief window is not slightly wrong.

**A run can have no answer, and that must survive the trip.** The lab
publishes a headline, the method behind it, and the tolerance it was
accepted under — and **declines to publish one** when its two
estimators disagree by more than that tolerance, setting
`headline_method` to `disagree` and `k_headline` to null. One of the
three recorded runs is in that state. The first version of this code
read `k_median` instead and reported 0.0881 as the result for a run
the lab refuses to answer for, so `has_answer` is now the first thing
these tools report, and `compare_lab_runs` refuses a pair where
either side is unanswered rather than showing how far the numbers
moved. Showing the movement invites picking one, which is choosing a
value the lab declined to choose.

**Not every published number is an estimator.** `k_median` is
derived, and worse than merely derived: it selects a *different*
estimator from run to run, equalling step on one run and phase on
another, so comparing two runs by their medians can compare step
against phase without saying so. That is not hypothetical — it was
done on this data and produced a confident wrong conclusion about the
estimates tracking the swept K range. It is excluded, and the
headline, which uses the same method on both sides, is what
`compare_lab_runs` moves.

`k_area` is excluded too, as a **cross-check rather than an
estimate**. Its model misfits systematically, reading 1.43x and 1.34x
the real estimators on two different materials, which is why the lab
publishes an `area_ratio` for it and why its own headline ignores it.
Leaving it in a spread put the apparent disagreement on this data at
25 to 43 percent; the estimators the headline is actually built from
agree to 2.0% and 0.9%. A spread is still disagreement between
methods rather than an error bar, but it has to be a spread across
methods that are trying to measure the thing.

**A run's plan holds warnings the result does not.** A sweep whose
top blocks command more extruder speed than the machine's `M203`
limit allows still runs, still analyses and still produces a
headline, with nothing in the analysis saying a third of its blocks
could not do as they were told. That happened: 17 of 51 blocks over
the limit on a real run. So `get_lab_run` reads the plan too and
reports `plan_warnings` beside the estimate, and attaches a line to
the estimate's own note when there are any. An unreadable plan is
reported as unknown rather than as a clean one, since empty and
unavailable are different answers and only the second hides
something.

**An up-down sweep's two passes are the substance, not a detail.**
The lab can sweep K up and back down, and reports each pass plus a
`drift_k`. Neither carries a `method`, so the estimator filter put
both in `other_keys` where the substance of such a run was
unreadable. They are rendered now, because the top-level `k_step` and
`k_phase` are the **means of the passes**: on a real run the passes
gave 0.09148 and 0.09733 and the headline figure is 0.09440, which is
neither measurement and looks like one. A one-way run would have
reported one pass with nothing to say the other existed.

The per-estimator movement between passes comes with it, and on that
run it says something the drift figure alone does not. Step moved
+0.00585 and phase −0.01514: **opposite directions**. A single steady
physical drift pushes every estimator the same way, so one clean
trend does not explain both, and "the run drifted" is exactly the
reading a lone drift number invites. The tool says so rather than
leaving it to be noticed.

**Blocks are not distinct K values.** A single `count` read 52 for a
sweep visiting 26 values twice, which invites reading 52 as the
sweep's resolution. `k_window` reports `blocks`, `distinct_k`, the
step and the `order`, and says how many times each value is visited
when those differ. For a one-way sweep they are equal, which is why
one field looked sufficient.

**Barely passing is not passing.** The lab's accept is one bit, and
one bit cannot tell a run that agreed comfortably from one that
scraped in. Two real runs used 39% and 93% of their tolerance, the
second sitting 0.00033 from a declared disagreement, and both read as
answered. `tolerance_used_percent` is reported, and above 80% the
note says the headline is weaker than one from a run that agreed
easily and that there is probably a reason the estimators struggled.
On that 93% run the reason was in the plan warning.

`compare_lab_runs` separates the parameter diff from the result diff,
since two runs with different settings and two analyses of one run are
different comparisons. Identical parameters make it a repeatability
check, and it says so: the difference between those estimates is the
lab's own run-to-run variation, which is the number any real effect
has to beat.

A `results` block also holds entries that are not estimates —
`capture` and `options` on real data — and those are listed by name
rather than shown as methods. Reporting them beside the estimators
with a null `k` says two methods failed, which is a worse statement
than the truth. An estimator with a method and no `k` is kept, though:
`delay` reports `delay_s`, and it is a real result rather than a
failed one.

### The print queue, and the one gate that is not here

`list_print_queue`, `get_queue_item`, `queue_print_file`,
`queue_local_file` and `remove_queued_item` work the lab's per-printer
queue.

**Adding cannot start a print**, and that is the property everything
else here follows from. The queue dispatches only to a printer a
person has set Ready after looking at the bed, and every start
consumes that Ready, so each print carries its own human check.
Nothing starts on a printer that is merely idle.

Which is why these tools have **no `confirm` argument**, unlike
`start_lab_run`. The gate already exists, upstream, and is held by
someone who can see the machine. A second one here would be friction
that buys nothing — and inventing friction where the risk is absent
teaches people to click through it where the risk is real.

**Ready set on the printer's touchscreen does not reach the lab.**
This firmware keeps Ready in RAM for Prusa Connect, and PrusaLink can
neither set it, clear it, nor report it, so the lab keeps its own.
Someone who set Ready at the machine and is wondering why nothing
started needs told that, not diagnosed, so `list_print_queue` says it
every time.

**Reorder, skip, retry, accept and retarget are absent on purpose**,
and the absence is argued rather than assumed:

- **reorder** changes which job the next Ready will start. A person
  setting Ready consents to whatever is at the head of the line; an
  agent that can move the head between the bed check and the Ready has
  made that consent about something else.
- **skip** looks like the safe one and is the clearest no. A failing
  preflight blocks the queue at that item deliberately, and skipping
  is one of three remedies a person chooses between. An agent skipping
  a blocked head is an agent clearing a safety stop.
- **retry** creates work after a failure nobody has read the cause of,
  onto a bed nobody has looked at since.
- **accept** asserts responsibility for printing a file nothing could
  check. A tool cannot carry that, and the lab is explicit that
  accepting never overrides a measured mismatch. There is no way to
  pass `accept_unchecked` from here at all.
- **retarget** moves an item to another printer, which is exactly what
  the serial pairing check exists to prevent.

Removing is allowed, but **only for items this server added**, checked
by reading the item first. That is the line between withdrawing your
own work and overriding somebody's decision about theirs.

`added_by` is **not a free text field**. It is how a person sees who
queued what, and a model that could set it to a person's name could
queue a job under that name, so the `mcprusa` prefix is fixed and a
caller may only append which session it is. Attribution an agent can
forge is worth nothing.

A queued item's `preflight` comes back verbatim, holds included,
because a blocked add is **worse than no add**: it stops the queue
there and everything behind it waits. The result says so in those
terms rather than reporting a successful queueing.

### Serving over HTTP, and refusing to

Default is stdio, which needs no authentication: the client spawned
the process and owns its pipes, and there is nobody else on them.

`MCPRUSA_TRANSPORT=http` serves instead on `MCPRUSA_HTTP_HOST` /
`MCPRUSA_HTTP_PORT` / `MCPRUSA_HTTP_PATH`, for the case stdio cannot
serve: one running server that several sessions reach. That is a
different security problem, because behind these tools is a machine
that heats to 300 °C and moves.

So **the server refuses to start on a reachable address with no
token**, and exits rather than binding. A short token is refused too,
under 24 characters, because a short token on a listening port reads
as protection and is not. Loopback with no token is fine, and says so
on stderr. The token is compared with `hmac.compare_digest`, never
logged, never in the startup banner, never returned by a tool.

Two choices here are worth the words.

**The refusal exits.** Everywhere else, a configuration this server
cannot use starts anyway and explains itself through the protocol,
because to an MCP client a dead process is a closed connection and
the message is lost. This is the opposite case twice over: whoever
started an HTTP server is reading a terminal or a compose log, and
the entire point of refusing is that the socket must not open. A
server that binds and then reports the problem has already accepted
the request that was the problem.

**A configured token applies on every transport**, stdio included.
Tying it to the transport would make a server authenticated or not
depending on a second setting, and the surprising direction of that
pair is the one where someone believes a token is in force and it is
not.

These five settings carry an `MCPRUSA_` prefix that the rest do not,
and that is not tidiness. This config class has no env prefix, so a
field called `http_port` answers to bare `HTTP_PORT` — an ordinary
variable to find already set in a container, deciding where this
binds. A stray `HTTP_TOKEN` is worse: it would satisfy the gate above
with a credential the operator never chose.

A `validation_alias` was the obvious fix and does not work. Without
`populate_by_name` pydantic ignores `Settings(http_token=...)`
**silently**, so a test asserting a token is in force passes against
no token; with it, the bare environment name is read again and the
hazard returns. Both measured. Prefixing the field has no such
trapdoor.

That bug was found by **running the server**, not by the suite. Every
test built `Settings` with keyword arguments, so none of them went
through environment parsing: the refusal messages named a variable
that did nothing, and the server fell back to stdio in silence. The
test that would have caught it now exists, and so does one asserting
the bare names are ignored.

### Why nothing long-running lives in here

This server does not host capture, polling or collection, and the
reasons are properties of what an MCP server *is* rather than
preferences. They are written down because the question recurs: the
case that prompted it was capturing the printer's accelerometer
resonance curves, which it streams as UDP metrics, but the answer
does not depend on that.

**A stdio MCP server dies with its client.** It is launched by
whoever connects and exits when they disconnect, so anything that has
to keep running while nobody is talking cannot live here. That one
settles it on its own.

**`stdout` is the transport.** The protocol is JSON-RPC over
stdin/stdout, so any stray write corrupts the stream. A UDP listener
writing session files is exactly the sort of component that
eventually wants to print, and a separate process removes the whole
class of mistake rather than policing it.

**The memory here is deliberately volatile.** The job and frame
caches are in-process and documented so that empty means *nobody was
looking*, not that nothing happened. Durable records in the same
object would quietly undo that distinction.

**The dependency list is a feature.** `uvx mcprusa` installs three
packages and talks to one device. Putting a broker or a database on
the path of printer control adds failure modes to tools that
currently need only the printer.

So the shape is a separate service that owns the long-running half,
and tools here that **read** from it over HTTP. HTTP specifically,
not a message bus: the questions this server asks are
request/response — list the sessions, fetch one, compare two — and
answering those from a subscription means waiting on retained
messages and inventing a timeout for a client that does not care
about liveness. A bus is right for fanning out live data to
dashboards, which is a different consumer.

## Setup

```bash
uv tool install mcprusa
claude mcp add prusa -- mcprusa
```

`uvx mcprusa` works too, but installing once removes per-launch
dependency resolution and with it a class of startup variance.

### An unconfigured server connects and says so

It does **not** exit. Exiting with a clear message on stderr is right
for a shell and wrong here: to an MCP client a process that exits is
`CONNECTION_CLOSED`, and the message is invisible because clients
rarely surface a dead server's stderr. That had a real user
hand-driving the server successfully while their client reported only
that it had closed.

So with configuration missing or malformed it starts, names itself
`mcprusa (not configured)`, and offers one tool, `check_configuration`,
which explains the problem through the protocol where the client will
actually show it.

Two settings are required. Both are on the printer's touchscreen under
**Settings > Network > PrusaLink**, and you want the **password**, not
the API key: the v1 API rejects the key.

**Pass them as environment variables.** This is the primary path,
because an MCP client launches the process and can hand them over
directly, with no file anywhere:

```jsonc
// .mcp.json
{ "mcpServers": { "prusa": {
    "command": "uvx", "args": ["mcprusa"],
    "env": {
      "PRINTER_HOST": "192.0.2.10",
      "PRUSALINK_PASSWORD": "your-printer-password"
    } } } }
```

**Or use a file.** These are read in order, later ones winning:

| Path | For |
|---|---|
| `~/.config/mcprusa/env` | one printer, every project |
| `./.env` | a project-local override |
| `$MCPRUSA_ENV_FILE` | an explicit path, **instead of** both |

`MCPRUSA_ENV_FILE` does not merely outrank the other two, it
**replaces** them. Nothing else is consulted, and a key that file
omits falls through to the built-in default rather than to the
user-level file. That is deliberate: naming a file means "use this
one", and anything else would let a value someone believed they had
excluded arrive anyway. It is also the clean way to pin a server to a
known config, and it makes the working directory irrelevant.

A server started by a client does not choose its working directory, so
a bare `./.env` is only found when the client happens to start it
somewhere that has one. The user-level file is the reliable one.

**But `./.env` wins where it exists, and that bites.** Registering
from a checkout, `uv run mcprusa` or `uv run --directory
/path/to/mcprusa mcprusa`, puts the working directory exactly where a
development `.env` is likely to sit. Observed: the printer moved
networks, the user-level file was corrected, and one server picked
the change up while another kept using the old address, an old camera
port-forward and a proxy that no longer existed. The two were
registered differently, which is the whole explanation and is not
visible from either config file.

That is the useful shape rather than the incident. **Which file wins
is a property of the registration, not of the files**, so comparing
env files cannot settle it and neither can asking another session
what it sees. Setting `MCPRUSA_ENV_FILE` in the registration removes
the question entirely. Otherwise, when a config change appears to
have no effect, print what that server actually loaded rather than
re-reading the file you edited:

```bash
uv run python -c "from mcprusa.config import load; s=load(); print(s.printer_host, s.camera_host)"
```

### `PRUSA_PROXY` needs a scheme

```bash
PRUSA_PROXY=socks5h://127.0.0.1:1080   # correct here
PRUSA_PROXY=127.0.0.1:1080             # correct for curl, NOT here
```

Both forms are right, for different tools. `curl --socks5-hostname`
takes a bare `host:port` and a scheme there would be wrong; `httpx`
demands one. Anyone driving the printer from a shell first will have
the bare form to hand and will copy it straight across, so this is
checked at startup and reported with the fix rather than reaching
httpx and raising `Unknown scheme for proxy URL` from deep in a
traceback.

The scheme is not guessed, because `socks5h`, `socks5` and `http` mean
different things and only you know which the proxy speaks.
`CAMERA_HOST`, by contrast, accepts `rtsp://host` as well as `host`,
since there is only one scheme an RTSP camera could mean.

Everything else is optional: `PRUSA_PROXY` for a tunnel, `CAMERA_HOST`
for snapshots, `NOZZLE_HIGH_FLOW` to make the flow check real, and
`PRUSACONNECT_API_KEY` for cloud upload. Run `uvx mcprusa` with nothing
set and it prints the whole list with examples.

### From a checkout

```bash
cp .env.example .env     # then fill in the password
uv sync --extra dev
uv run pytest
uv run pytest --cov=mcprusa --cov-report=term-missing
```

### What the tests do and do not cover

91% of statements across 630 tests, measured 2026-11-02, and the gap is stated here
rather than left for someone to discover. Covered paths were measured,
not assumed: running coverage found that `upload_print_file` had never
executed once, despite being documented above, and that the whole of
the camera module was reachable without hardware and simply untested.

This figure was 94% and said so here for several releases while
drifting to 89%, because code landed and the sentence describing the
old state kept reading as current. Re-measure it when you change this
line; a documented number decays silently and in exactly the same way
the slicer's exit status does.

Not covered, deliberately:

- `main()` and `run()`, the stdio entry point. Exercised end to end
  instead by registering the server through `.mcp.json` and driving it
  from a separate client over real stdio.
- One line in `camera.py`: the successful return of frame bytes. Every
  failure path around it is tested; this needs a camera.

Not covered, and worth knowing: the delete-retry backoff and streamed
`download_head` in `client.py`, and the zeroconf mDNS browser in
`discovery.py`, which needs the optional extra installed and a
responder on the network.

`slice_model` is tested against a **stub** standing in for
PrusaSlicer. That is not a convenience. What is under test is what the
wrapper concludes from what the slicer left behind, and a stub
produces the combinations that matter — exited cleanly and wrote
nothing, died on a signal but wrote a file — in a tenth of a second
each, where the real slicer produces one of them only on some
machines. The argv the stub receives is asserted too, so it cannot
drift into testing a command line the real slicer would never see.
The suite was checked against three deliberate regressions: trusting
the exit code, skipping the stale-artefact delete, and re-asserting a
fixed exit code in the `--merge` message. Each failed, the last two
in exactly one test.

The void walker is tested the same way on hand-written gcode, plus
**one test against a real slice**, which is the only thing that can
show it agrees with gcode nobody here wrote. It builds a cantilevered
plate, slices it with supports on, and asserts both that support is
found under the plate and that a control region of empty bed is
clear. The control is the half that matters: a walker counting
everything would also have "found" the first. It skips when
PrusaSlicer is absent.

That test is also where the strongest check on the hand-written ones
came from. A mutation that counted every `E` word as extrusion did
*not* fail the retraction test, because the retraction sat outside
the void anyway. Rewriting it as a wipe that retracts while crossing
the hole — which is the shape real gcode has — made it bite.

One test is load-bearing in a way that is easy to undo. The suite
collapses the control tools' settle ceiling to zero (see
`tests/conftest.py`), which means the polling loop executes its body
exactly once nearly everywhere. `test_the_settle_loop_actually_comes_
back_and_looks_again` overrides that against a printer which reflects a
change only on a later read, and it is the single test that fails if the
loop stops looping. Verified by breaking the loop on purpose: 144 tests
passed and only that one caught it.

Credentials are on the printer's touchscreen under
**Settings > Network > PrusaLink**. Use the **password**, not the API
key: the v1 API rejects `X-Api-Key`, which only satisfies the legacy
OctoPrint-compatible `/api/*` shim.

Register it with Claude Code:

```bash
claude mcp add prusa -- uv run --directory /path/to/mcprusa mcprusa
```

## Notes on the API itself

**Loaded filament lives only on the legacy endpoint.** There is no
filament route on v1; `/api/v1/filament`, `/api/v1/printer` and
`/api/v1/settings` all 404. The loaded material appears in exactly one
place, `telemetry.material` on `GET /api/printer`. That shim is otherwise
unusable for state: during one print it reported `"Printing"` with both
`printing: true` and `error: true` set, at the same moment v1 correctly
said `ATTENTION`. So the server reads it for that single field and
nothing else.

Nothing in PrusaLink can load, unload or change filament, so the server
can observe a mismatch and refuse but cannot fix one. In practice
somebody walks to the touchscreen.

Stated that way on purpose. The **firmware** has `M701` (load, with
`S"Filament"` to pick a material by name), `M702` (unload) and `M1600`
(change), so "impossible" would be wrong; what is established is that
PrusaLink exposes no route to them and no raw G-code channel. Whether
they are reachable by uploading a one-line `.gcode` and printing it is
**untested here**, and that is the same open question gating other
remote-G-code ideas. Prusa Connect's published SDK also lists
`LOAD_FILAMENT` and `UNLOAD_FILAMENT` among its commands, though that
enum is not authoritative for this printer's firmware — it omits
`CANCEL_OBJECT`, which the Core One implements, and does not list the
Core One at all.

This is the project's own recurring mistake, caught in its own
documentation: a search complete within one interface reads exactly
like a search complete everywhere.

**An empty listing does not mean an empty drive.** With no USB inserted,
`/api/v1/storage` honestly reports `"available": false`, but
`/api/v1/files/usb/` still returns a healthy-looking empty directory.
Uploads then fail late with `507 Insufficient Storage`. The server gates
on the `available` flag and says "no USB drive in the printer".

**`.local` does not resolve from every host.** The printer publishes
`prusa-core-one.local` and answers unicast mDNS, but multicast
resolution fails where route selection sends `224.0.0.251` out the wrong
interface. Take the host from config.

## Further reading

- [`docs/slicing-verification.md`](docs/slicing-verification.md) — an
  approved, unbuilt layer that verifies slicer output rather than
  trusting its exit code, with the measured failures it exists for.
- [`docs/method-notes.md`](docs/method-notes.md) — how the defects in
  this project were actually found. Almost none were code that
  errored; they were code that confidently described a world it had
  never checked.

## Credits

The API surface, the 8.3 naming trap and the storage-flag behaviour were
mapped by a sibling project whose bash CLI
is a working bash CLI over the same endpoints. The startup trace in
its measured traces of real prints are the source of every threshold
in the phase interpreter.
