Metadata-Version: 2.5
Name: mcprusa
Version: 2026.10.12.5
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: httpx[socks]>=0.28
Requires-Dist: pydantic-settings>=2.6
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.

## 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, nothing printed yet |
| `attention_filament_change` | 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 two attention phases are the same thing as far as the printer is
concerned, and that is the point. A mid-print colour change and the
dialog a job raises before it heats anything both report `ATTENTION` on
`/api/v1/status` and `PAUSED` on `/api/v1/job`. The state fields cannot
separate them, and they are opposite situations: one is a cold printer
with nothing on the bed, the other is most of a finished part. What
separates them is `progress` and the heater targets.

That distinction is load-bearing. `cancel_print` is the only remote way
out of either state, 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`.

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.

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` | frames over RTSP | none |

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

Enabling it 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.

**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.

## 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 leaves the printer IDLE, 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 at
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.

## 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` says
plainly that flow was *not* verified and what happens if it is wrong.
Leave it unset and `get_printer_info` reports the rating as unknown
rather than letting silence read as safe.

### Upload can do the real check, because the file is here

`start_print` has only a mangled filename to go on. `upload_print_file`
has the file, and the slicer wrote the answer inside it. 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 have no decoder
here and 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.

**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.

**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.

## 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.

## 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, beats both |

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.

### `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

94% of statements, 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.

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.

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. That is
touchscreen-only, so the server can observe a mismatch and refuse, but it
cannot fix one.

**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.

## 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.
