mcprusa 2026.10.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ # Copy to .env and fill in. .env is gitignored.
2
+
3
+ # The printer's address. This is configuration, not discovery: the lease
4
+ # is dynamic with no reservation, and prusa-core-one.local does not
5
+ # resolve from every host, so a name here that stops working means the
6
+ # lease moved rather than that the printer is broken.
7
+ #
8
+ # The value below is a documentation placeholder (RFC 5737), not a real
9
+ # address. Find yours on the printer's touchscreen under Settings >
10
+ # Network, or scan the LAN for the host that answers a request to
11
+ # /api/version with realm="Printer API".
12
+ PRINTER_HOST=192.0.2.10
13
+
14
+ # Digest auth. Username is "maker" on stock Buddy firmware. Both the
15
+ # password and a separate API key are on the printer's touchscreen under
16
+ # Settings > Network > PrusaLink: use the PASSWORD. The v1 API rejects
17
+ # X-Api-Key, which only satisfies the legacy /api/* shim.
18
+ PRUSALINK_USER=maker
19
+ PRUSALINK_PASSWORD=
20
+
21
+ # Storage target. The Core One reports exactly one, "usb".
22
+ PRUSALINK_STORAGE=usb
23
+
24
+ # The Buddy3D camera, if you have one paired. It is NOT a printer
25
+ # peripheral: the printer only supplies it 5V over USB, and it joins the
26
+ # network as its own host with its own DHCP lease. PrusaLink exposes no
27
+ # camera route at all, so this address is independent of PRINTER_HOST
28
+ # and can move on its own. Leave empty if there is no camera; that is a
29
+ # normal state and the server will say so rather than failing.
30
+ #
31
+ # TWO steps, and the second is easy to miss. Pair the camera by scanning
32
+ # the QR code Prusa Connect generates, THEN set "Streaming" to enabled on
33
+ # Connect's camera page (or in the Prusa app). RTSP is off by default and
34
+ # until it is switched on every port on the camera is closed, so the
35
+ # stream looks exactly like a camera that is not there. Connect showing
36
+ # live images does not mean streaming is on: those arrive by cloud
37
+ # upload, are 10 to 90 seconds stale by design, and do not use RTSP.
38
+ #
39
+ # The camera's address is its own DHCP lease, separate from the
40
+ # printer's, so it can move while the printer stays put.
41
+ CAMERA_HOST=
42
+ CAMERA_RTSP_PATH=/live
43
+
44
+ # Timeouts. An upload of a large .bgcode needs far longer than a poll.
45
+ REQUEST_TIMEOUT_SECONDS=30
46
+ UPLOAD_TIMEOUT_SECONDS=600
47
+ CAMERA_SNAPSHOT_TIMEOUT_SECONDS=20
48
+
49
+ # Nozzle targets the firmware picks for itself during print startup, used
50
+ # to tell the startup phases apart. These are machine constants rather
51
+ # than material settings (170 is the reported minimum extrusion
52
+ # temperature, 150 is the loadcell probe temperature), so they should
53
+ # only need changing if a firmware revision moves them.
54
+ STANDBY_NOZZLE_TEMP=170
55
+ PROBE_NOZZLE_TEMP=150
56
+
57
+ # Is a high-flow nozzle fitted? PrusaLink cannot tell you: /api/v1/info
58
+ # reports nozzle_diameter and mmu and nothing about flow rating, while
59
+ # Prusa's cloud carries the full tool record. So the preflight only
60
+ # knows what you write here.
61
+ #
62
+ # It matters because a file sliced for the wrong flow rating stops the
63
+ # printer with a touchscreen dialog, and nothing in PrusaLink can answer
64
+ # a dialog. On a printer nobody is standing next to, that is a stop with
65
+ # no remote remedy. Leave unset to have it reported as unknown rather
66
+ # than assumed safe.
67
+ NOZZLE_HIGH_FLOW=
68
+
69
+ # How to reach the printer, as distinct from where it is. Empty means a
70
+ # direct connection. Use socks5h:// rather than socks5:// so a hostname
71
+ # resolves at the proxy's end; it makes no difference for a literal IP.
72
+ #
73
+ # A failure here is reported as a proxy failure rather than as a printer
74
+ # that is off, because those produce identical silence and have
75
+ # completely different fixes.
76
+ PRUSA_PROXY=
77
+
78
+ # Prusa Connect: a cloud route to the same printer, useful when the
79
+ # local one is unreachable. The printer reaches Connect outbound, so
80
+ # this works without a tunnel and across the printer changing networks.
81
+ #
82
+ # UPLOAD ONLY, and that is a property of the credential rather than a
83
+ # missing feature. This key identifies THE PRINTER to Connect, not you:
84
+ # its last eight characters are the first segment of the printer's
85
+ # UUID. State, job data and the command endpoints need a user
86
+ # credential that only exists inside a logged-in browser session, which
87
+ # no server can hold. Verified: a bad key gives 401 on the upload route
88
+ # and this one does not, while this one gives 401 on every state and
89
+ # command route.
90
+ #
91
+ # Found on the printer's page in Prusa Connect. Take the key labelled
92
+ # for Prusa Connect, not the PrusaLink one beside it.
93
+ PRUSACONNECT_API_KEY=
@@ -0,0 +1,7 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
6
+ .ruff_cache/
7
+ .pytest_cache/
@@ -0,0 +1,477 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcprusa
3
+ Version: 2026.10.11
4
+ Summary: MCP server for the Prusa Core One, over the on-board PrusaLink API
5
+ Author-email: Ryan Malloy <ryan@supported.systems>
6
+ License: MIT
7
+ Keywords: 3d-printing,fastmcp,mcp,prusa,prusalink
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: fastmcp<5,>=4.1.0
10
+ Requires-Dist: httpx[socks]>=0.28
11
+ Requires-Dist: pydantic-settings>=2.6
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
+ Requires-Dist: pytest-cov>=7.1; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3; extra == 'dev'
16
+ Requires-Dist: ruff>=0.8; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mcprusa
20
+
21
+ An MCP server for the Prusa Core One, wrapping the printer's on-board
22
+ PrusaLink HTTP API so an LLM session can read printer state, browse the
23
+ files on the USB drive, and start or stop a print.
24
+
25
+ This is a machine that gets hot and commits hours of filament, so the
26
+ tool surface is not a flat mirror of the API. Reads are generous. Writes
27
+ have friction, and the places where the API will happily mislead you are
28
+ handled rather than passed through.
29
+
30
+ ## What it refuses to do
31
+
32
+ Three of these are the whole reason the server exists.
33
+
34
+ **It never reports that a print succeeded.** `progress` reaching 100%
35
+ says the gcode stream was consumed, not that an object is on the bed. A
36
+ print that detached ten minutes in still reports 100%. The server says
37
+ "the printer reports 100%" and tells you to look.
38
+
39
+ **It never trusts `state` to tell it a print is over.** The printer sat
40
+ at 100% still reporting `PRINTING` for 41 and 44 measured seconds on two
41
+ prints, with the heater targets already at zero and the gantry parked.
42
+
43
+ This is not one endpoint lagging another. **Both** report `PRINTING`
44
+ throughout, because the firmware's `PRINTING` includes the park-and-cool
45
+ phase after the last layer. So neither state field means "the gcode has
46
+ finished", and `progress` is the only signal that does. Budget a minute.
47
+
48
+ The two endpoints do not lag each other at all: 850 samples at 1s
49
+ resolution found zero disagreements, and both edges of a pause were
50
+ single-sample flips. Their state fields are still never compared for
51
+ equality, but the reason is read ordering in the *client*, not behaviour
52
+ in the printer. Anything that fetches them as two sequential calls, this
53
+ server included, can see a transition land between the reads at any
54
+ sampling rate.
55
+
56
+ The control tools poll for the state change rather than waiting a fixed
57
+ period, because a fixed wait cannot tell "the printer was slower than I
58
+ guessed" apart from "the request did nothing", and would report both as
59
+ success.
60
+
61
+ **It never reports a started print off the start call alone.** Creating a
62
+ job returns success and a job id while the printer has set no heater
63
+ targets at all and may be waiting on a touchscreen dialog. The server
64
+ polls state afterwards and tells you what it actually observed.
65
+
66
+ **It never guesses which file you meant.** The USB is FAT and the
67
+ firmware addresses files by mangled 8.3 short names, which collide across
68
+ genuinely different models: the stock drive carries `BENCHY~1.BGC` and
69
+ `BENCHY~2.BGC`, plus `KEYCHA~1.BGC` and `KEYCHA~2.BGC`. An ambiguous
70
+ name fails with the candidate list rather than picking one. Two files on
71
+ that drive are 19 and 22 hours long.
72
+
73
+ ## The startup sequence looks broken and is not
74
+
75
+ This is the thing most likely to cause damage through good intentions. For
76
+ the first five minutes of a print the Core One:
77
+
78
+ 1. Heats the bed while holding the nozzle at 170C, below melting point
79
+ 2. Actively **cools** the nozzle to 150C with the fan at ~5700, because a
80
+ hot nozzle oozes and corrupts the loadcell bed touch
81
+ 3. Probes, with `axis_z` going **negative** (-2.4, -0.9, -0.3)
82
+ 4. Runs the mesh points
83
+ 5. Reheats to the print temperature and lays layer one
84
+
85
+ Throughout all of that, `progress` reads `0.00%`. First movement off zero
86
+ was measured at 333 seconds. `axis_z` is not a progress proxy either: it
87
+ reads 10, then 4, then 40, then negative, then 15, all before any plastic.
88
+
89
+ A reader handed those raw fields reasonably concludes the printer is
90
+ malfunctioning, and the helpful thing to do about a malfunctioning printer
91
+ is cancel the job. So `get_status` returns an interpreted **phase**
92
+ alongside the raw fields rather than instead of them:
93
+
94
+ | Phase | What it means |
95
+ |---|---|
96
+ | `heating_bed` | Bed climbing, nozzle parked at standby |
97
+ | `cooling_for_probe` | Nozzle cooling on purpose, fan hard on |
98
+ | `probing` | Loadcell finding the bed, Z at and below zero |
99
+ | `reheating` | Climbing to print temperature after the cold probe |
100
+ | `first_layer` | Laying layer one, part-cooling fan still off |
101
+ | `printing` | Past the first layer |
102
+ | `attention_before_heating` | A dialog is waiting, nothing printed yet |
103
+ | `attention_filament_change` | Paused mid-print, a part is on the bed |
104
+ | `created_not_started` | A job exists but no heater has been touched |
105
+ | `reported_complete_unverified` | 100% consumed, outcome unknown |
106
+
107
+ Those two attention phases are the same thing as far as the printer is
108
+ concerned, and that is the point. A mid-print colour change and the
109
+ dialog a job raises before it heats anything both report `ATTENTION` on
110
+ `/api/v1/status` and `PAUSED` on `/api/v1/job`. The state fields cannot
111
+ separate them, and they are opposite situations: one is a cold printer
112
+ with nothing on the bed, the other is most of a finished part. What
113
+ separates them is `progress` and the heater targets.
114
+
115
+ That distinction is load-bearing. `cancel_print` is the only remote way
116
+ out of either state, so a tool that treats them alike will throw away a
117
+ nearly-finished print while reporting that it resolved an attention
118
+ state. Cancelling a print with any progress therefore requires
119
+ `acknowledge_discarding_progress`.
120
+
121
+ Every threshold in the interpreter comes from a measured trace of a real
122
+ print rather than from reading docs, and `tests/test_phases.py` replays
123
+ every row of that trace. The interpreter keys on `target_nozzle` together
124
+ with the sign of `temp_nozzle - target_nozzle`, which carries far more
125
+ signal than temperature alone. The 170 and 150 targets are machine
126
+ constants, not material settings, so the interpretation holds for a
127
+ filament nobody has captured yet.
128
+
129
+ ## Two independent sources
130
+
131
+ The camera is not a printer peripheral. The Core One only supplies it 5V
132
+ over USB; it is a separate WiFi device with its own DHCP lease, and
133
+ PrusaLink exposes no camera route at all (`/api/v1/cameras`,
134
+ `/api/v1/camera`, `/api/v1/cameras/snap` and `/api/cameras` all 404).
135
+
136
+ | Source | Config | Carries | Auth |
137
+ |---|---|---|---|
138
+ | PrusaLink | `PRINTER_HOST` | state, files, job control | HTTP Digest |
139
+ | Buddy3D camera | `CAMERA_HOST` | frames over RTSP | none |
140
+
141
+ The camera is optional and absent by default. Its absence is a normal
142
+ state the server reports plainly, not a startup failure.
143
+
144
+ Enabling it takes **two** steps and the second is easy to miss. Pair the
145
+ camera by scanning the QR code Connect generates, then switch
146
+ **Streaming** on from Connect's camera page. RTSP is off by default, and
147
+ until it is enabled every port on the camera is closed, so a camera that
148
+ is paired but not streaming is indistinguishable over the network from
149
+ one that is absent. Connect showing live images does not mean streaming
150
+ is on: those arrive by cloud upload, not RTSP, and are 10 to 90 seconds
151
+ stale by design. A frame that corresponds to *now* has to come from the
152
+ local stream.
153
+
154
+ Because those two causes cannot be told apart from here, a failed
155
+ snapshot names both and says which setting to go and check.
156
+
157
+ **Never identify a camera by whether it needs a password.** More than
158
+ one Buddy cam can sit on the same network, and whether a stream carries
159
+ auth is a configuration choice, not an identity. One observed pair:
160
+ `.245` serves `/live` with no auth, `.13` demands digest auth with
161
+ realm `LIVE555 Streaming Media` — and LIVE555 is a generic embedded
162
+ RTSP stack used by many devices, so the realm names the software, not
163
+ the vendor. Both are Prusa cameras.
164
+
165
+ So the address is **configuration, never discovery**. A scan can land
166
+ on the wrong printer's camera and return a confident frame of entirely
167
+ the wrong machine, which is worse than no frame because it looks like
168
+ success. If the frames show a machine you did not expect, say so.
169
+
170
+ **ffmpeg cannot use a SOCKS proxy for RTSP.** When the camera is only
171
+ reachable through a jump host, the SOCKS forward that serves PrusaLink
172
+ will not serve the camera. Use a port forward instead:
173
+
174
+ ```bash
175
+ ssh -L 127.0.0.1:18555:<camera-ip>:554 <jump-host>
176
+ # then CAMERA_HOST=127.0.0.1:18555
177
+ ```
178
+
179
+ TCP transport (which this already uses) keeps all media interleaved on
180
+ that single forwarded connection.
181
+
182
+ **The camera cannot be recovered remotely.** It is paired by QR code to
183
+ a specific SSID, not to the printer. Move the printer to a site where
184
+ that network does not exist and the camera cannot rejoin, even though
185
+ the printer itself will. Getting it back needs a fresh QR for the new
186
+ wifi *and* physical access to the camera's reset button. So a tunnel to
187
+ a relocated printer buys PrusaLink and nothing visual, and no amount of
188
+ configuration here changes that.
189
+
190
+ **The stream is unauthenticated.** `rtsp://<camera>/live` asks for no
191
+ credentials at all, so anyone on the same network can watch the printer.
192
+ On a flat ISP wifi, which is where this printer lives, that is everyone
193
+ on that wifi. Worth knowing before enabling streaming.
194
+
195
+ **The frame is evidence, not a verdict, and may not even contain the
196
+ subject.** The fixed mounting shows the front half of the bed with the
197
+ gantry across the top. During a print the part is often not
198
+ identifiable; once the job finishes the gantry parks at Z=225 directly
199
+ across the field of view and blocks the bed almost entirely. The camera
200
+ has motorised pan and tilt precisely because one fixed view is usually
201
+ wrong, and nothing exposes that locally.
202
+
203
+ So the snapshot tool's description says: describe what is visible, say
204
+ that judging print quality needs a person's eyes, and never report that
205
+ a print looks fine or has failed. The camera answers "what does it look
206
+ like", which is a different question from "did it work", and sometimes
207
+ it cannot even answer that one.
208
+
209
+ **Two frames beat one, and the tool returns two by default.** A single
210
+ frame is ambiguous in two ways at once: the toolhead occludes whatever
211
+ it is parked over, and a 2mm-tall grey object on a grey sheet has
212
+ almost no contrast. A second frame a few seconds later fixes both,
213
+ because the head moves and the part does not.
214
+
215
+ This started as reasoning after misreading an occluded part as an empty
216
+ bed, and was then demonstrated on live hardware. Two frames, seven
217
+ seconds apart, during a healthy print: the first showed the parts
218
+ plainly with clean walls and regular infill, the second had the
219
+ toolhead directly over them hiding almost everything. Same printer,
220
+ same print, same minute. One frame said healthy, the other said
221
+ unreadable.
222
+
223
+ Seven seconds is about right; much shorter risks both frames catching
224
+ the head in the same region on a small part.
225
+
226
+ **But two frames stop helping the moment a print finishes.** The trick
227
+ beats a *moving* obstruction; a parked gantry is a static one. After
228
+ `FINISHED` the beam sits straight across the field of view at Z=225.7
229
+ and nothing moves, so the second frame is the first frame and neither
230
+ shows the bed. Two frames taken seven seconds apart of a finished
231
+ plate came back identical and useless, with a sliver of sheet visible
232
+ and none of the four parts.
233
+
234
+ That is exactly when someone asks how it came out, so the server keeps
235
+ a frame from while the print was still running:
236
+
237
+ | | Job object | Bed view |
238
+ |---|---|---|
239
+ | During the print | available | visible |
240
+ | After `FINISHED` | **gone** | **hidden by the gantry** |
241
+ | Fix | cache on every status read | cache on every camera read |
242
+
243
+ Same shape as the job cache, for the same reason: the thing you want
244
+ to report on disappears at the moment you want to report it, so it has
245
+ to be captured beforehand. `get_camera_snapshot` returns that kept
246
+ frame alongside the live ones, labelled with the progress at the time,
247
+ and says plainly when nothing was kept — which means nobody was
248
+ looking, not that nothing printed.
249
+
250
+ Three other ways out were considered and rejected for now: aiming the
251
+ camera lower is a one-time physical act needing Connect; `MOVE_Z` to
252
+ drop the bed exists only in Connect's command set and moves a machine
253
+ to take a photograph; and neither is available over the local API.
254
+
255
+ ## Transport is configured, not assumed
256
+
257
+ `PRINTER_HOST` says *where* the printer is. `PRUSA_PROXY` says *how* to
258
+ get there, and it is optional:
259
+
260
+ ```bash
261
+ PRINTER_HOST=192.0.2.10
262
+ PRUSA_PROXY=socks5h://127.0.0.1:1080 # empty for a direct connection
263
+ ```
264
+
265
+ This exists because within two days this printer was on wifi, then on
266
+ ethernet behind a route that could not reach it, then on ethernet
267
+ through an SSH forward. Prefer `socks5h://` over `socks5://` so a
268
+ hostname resolves at the proxy's end; it makes no difference for a
269
+ literal IP, but a name that only resolves on the far side will
270
+ otherwise fail locally.
271
+
272
+ A proxy failure is reported as **a proxy failure**, not as a printer
273
+ that is off. Those produce identical silence and have completely
274
+ different fixes, and the whole afternoon that produced this note was
275
+ spent blaming the second for the first. If the forward dies, the error
276
+ says the route failed and that the printer may be perfectly healthy.
277
+
278
+ ### The routing trap worth knowing
279
+
280
+ A destination can be unreachable while `ip route get` looks fine. A
281
+ docker compose network claiming `192.168.0.1/20` silently swallows
282
+ everything from `192.168.0.0` to `192.168.15.255`, so a printer at
283
+ a printer inside that span resolves to a bridge interface that may
284
+ not even be up.
285
+ Longest-prefix match means that bridge wins before any VPN or policy
286
+ route is consulted, and a SOCKS proxy inherits the same table.
287
+
288
+ The check that catches it is reading **which interface** came back, not
289
+ whether anything did. A missing route is obvious; a wrong one looks like
290
+ a working one.
291
+
292
+ ## One check PrusaLink cannot make: nozzle flow rating
293
+
294
+ `/api/v1/info` reports `nozzle_diameter` and `mmu` and nothing else
295
+ about the tool. Prusa's cloud carries the full record
296
+ (`{"nozzle_diameter": 0.4, "high_flow": true, "hardened": false}`), but
297
+ the local API does not expose `high_flow` at all, and the slicer does
298
+ not put it in the file name either.
299
+
300
+ That gap has teeth. A file sliced for standard flow on a high-flow
301
+ nozzle stops the printer with a touchscreen dialog, and nothing in
302
+ PrusaLink can answer a dialog. On a printer nobody is standing next to,
303
+ that is a stop with no remote remedy.
304
+
305
+ So `NOZZLE_HIGH_FLOW` is configuration. Set it and `start_print` says
306
+ plainly that flow was *not* verified and what happens if it is wrong.
307
+ Leave it unset and `get_printer_info` reports the rating as unknown
308
+ rather than letting silence read as safe.
309
+
310
+ ### Upload can do the real check, because the file is here
311
+
312
+ `start_print` has only a mangled filename to go on. `upload_print_file`
313
+ has the file, and the slicer wrote the answer inside it. So uploads get
314
+ a genuine comparison of flow rating, filament type and nozzle diameter
315
+ against the machine, and a real disagreement refuses while quoting both
316
+ values.
317
+
318
+ That is a different kind of refusal from one invented out of missing
319
+ information, which is why `acknowledge_preflight_failure` **cannot**
320
+ override it. The flag exists for a file whose metadata could not be
321
+ read. A measured contradiction is not a judgement call to wave away,
322
+ and a refusal people learn to click through protects nothing.
323
+
324
+ The container is parsed from Prusa's `libbgcode` specification rather
325
+ than scanned for strings, because bgcode permits compressed metadata
326
+ blocks and a string scan finds nothing in those — then reports no
327
+ mismatch, which reads as a pass. Heatshrink blocks have no decoder
328
+ here and are reported as unreadable, which is deliberately its own
329
+ outcome. Unreadable metadata still uploads, since storing a file is
330
+ harmless; it blocks `start_printing` until someone acknowledges it.
331
+
332
+ ## Prusa Connect: a second route, for sending files only
333
+
334
+ `send_file_via_connect` pushes a gcode file to the printer through
335
+ Prusa's cloud. The printer reaches Connect outbound, so this works
336
+ without a tunnel, without being on the printer's network, and across
337
+ the printer moving networks entirely. It runs the same file preflight
338
+ as a direct upload, degrading the checks that need the printer when it
339
+ cannot be reached.
340
+
341
+ **It cannot do anything else, and that is a property of the credential
342
+ rather than a missing feature.** `PRUSACONNECT_API_KEY` identifies *the
343
+ printer* to Connect, not the account holder: its last eight characters
344
+ are the first segment of the printer's UUID, and it is what the machine
345
+ presents when pushing telemetry up. Measured:
346
+
347
+ | Route | Bad key | This key |
348
+ |---|---|---|
349
+ | `POST /api/files/local` (upload) | 401 | accepted |
350
+ | `/app/printers`, mobile API, state, commands | 401 | **401** |
351
+
352
+ State, job data, temperatures and the command endpoints need a user
353
+ credential. There isn't one a server can hold — see below.
354
+
355
+ ## Why state and control stay on PrusaLink
356
+
357
+ Connect is Prusa's cloud. Its API is **more** capable than the local
358
+ one, not less, so the reason state and control stay local is neither
359
+ data quality nor capability: it is that **there is no credential a
360
+ server can hold. Only a person who is logged in.**
361
+
362
+ Its telemetry is not stale: it arrives over MQTT and tracks
363
+ continuously. And `/app/printers/{uuid}/supported-commands` returns 46
364
+ commands with their arguments and the printer states each is legal in,
365
+ including several this server has to tell you are impossible:
366
+
367
+ - `DIALOG_ACTION` answers a touchscreen dialog remotely, which is
368
+ exactly what PrusaLink refuses with a 409
369
+ - `LOAD_FILAMENT` / `UNLOAD_FILAMENT`, which PrusaLink cannot do at all
370
+ - `MOVE`, `HOME`, `MESH_BED_LEVELING`, `CANCEL_OBJECT`, `SET_SPEED`,
371
+ `SET_FLOW`, temperature control, even firmware flash
372
+
373
+ Against that: there is no official third-party API. The only documented
374
+ one is the Camera API, for pushing snapshots *in*. The rest are private
375
+ frontend endpoints that take cookie auth rather than a token, reached
376
+ through an unofficial OAuth2 refresh against `account.prusa3d.com` that
377
+ Prusa can change without notice, and reportedly will not accept
378
+ `X-Api-Key` or `Authorization` at all.
379
+
380
+ That last point was established rather than assumed. The one session
381
+ that reached those endpoints did it by running `fetch(..., {credentials:
382
+ 'include'})` inside a logged-in browser, so the browser attached a
383
+ human's session itself. That session died with the browser about two
384
+ hours later, with a console line about a refresh timer that lives in
385
+ the page. The mobile API's own spec lists a `server_to_server` security
386
+ scheme in its global security block and then **never defines it** in
387
+ `securitySchemes`.
388
+
389
+ So a Connect backend for state would be a reverse-engineered transport
390
+ that works until a person's browser session ends, then silently 401s,
391
+ while controlling a machine that gets hot. PrusaLink is documented,
392
+ stable, authenticated and local. If the printer is unroutable, an SSH
393
+ forward to a host that can see it keeps the transport honest and
394
+ changes nothing above it.
395
+
396
+ Worth knowing rather than forgetting, though: the ATTENTION state this
397
+ server reports as needing a person *is* remotely answerable, just not by
398
+ anything here.
399
+
400
+ ## Setup
401
+
402
+ ```bash
403
+ cp .env.example .env # then fill in the password
404
+ uv sync --extra dev
405
+ uv run pytest
406
+ uv run pytest --cov=mcprusa --cov-report=term-missing
407
+ ```
408
+
409
+ ### What the tests do and do not cover
410
+
411
+ 94% of statements, and the gap is stated here rather than left for
412
+ someone to discover. Covered paths were measured, not assumed: running
413
+ coverage found that `upload_print_file` had never executed once, despite
414
+ being documented above, and that the whole of the camera module was
415
+ reachable without hardware and simply untested.
416
+
417
+ Not covered, deliberately:
418
+
419
+ - `main()` and `run()`, the stdio entry point. Exercised end to end
420
+ instead by registering the server through `.mcp.json` and driving it
421
+ from a separate client over real stdio.
422
+ - One line in `camera.py`: the successful return of frame bytes. Every
423
+ failure path around it is tested; this needs a camera.
424
+
425
+ One test is load-bearing in a way that is easy to undo. The suite
426
+ collapses the control tools' settle ceiling to zero (see
427
+ `tests/conftest.py`), which means the polling loop executes its body
428
+ exactly once nearly everywhere. `test_the_settle_loop_actually_comes_
429
+ back_and_looks_again` overrides that against a printer which reflects a
430
+ change only on a later read, and it is the single test that fails if the
431
+ loop stops looping. Verified by breaking the loop on purpose: 144 tests
432
+ passed and only that one caught it.
433
+
434
+ Credentials are on the printer's touchscreen under
435
+ **Settings > Network > PrusaLink**. Use the **password**, not the API
436
+ key: the v1 API rejects `X-Api-Key`, which only satisfies the legacy
437
+ OctoPrint-compatible `/api/*` shim.
438
+
439
+ Register it with Claude Code:
440
+
441
+ ```bash
442
+ claude mcp add prusa -- uv run --directory /path/to/mcprusa mcprusa
443
+ ```
444
+
445
+ ## Notes on the API itself
446
+
447
+ **Loaded filament lives only on the legacy endpoint.** There is no
448
+ filament route on v1; `/api/v1/filament`, `/api/v1/printer` and
449
+ `/api/v1/settings` all 404. The loaded material appears in exactly one
450
+ place, `telemetry.material` on `GET /api/printer`. That shim is otherwise
451
+ unusable for state: during one print it reported `"Printing"` with both
452
+ `printing: true` and `error: true` set, at the same moment v1 correctly
453
+ said `ATTENTION`. So the server reads it for that single field and
454
+ nothing else.
455
+
456
+ Nothing in PrusaLink can load, unload or change filament. That is
457
+ touchscreen-only, so the server can observe a mismatch and refuse, but it
458
+ cannot fix one.
459
+
460
+ **An empty listing does not mean an empty drive.** With no USB inserted,
461
+ `/api/v1/storage` honestly reports `"available": false`, but
462
+ `/api/v1/files/usb/` still returns a healthy-looking empty directory.
463
+ Uploads then fail late with `507 Insufficient Storage`. The server gates
464
+ on the `available` flag and says "no USB drive in the printer".
465
+
466
+ **`.local` does not resolve from every host.** The printer publishes
467
+ `prusa-core-one.local` and answers unicast mDNS, but multicast
468
+ resolution fails where route selection sends `224.0.0.251` out the wrong
469
+ interface. Take the host from config.
470
+
471
+ ## Credits
472
+
473
+ The API surface, the 8.3 naming trap and the storage-flag behaviour were
474
+ mapped by a sibling project whose bash CLI
475
+ is a working bash CLI over the same endpoints. The startup trace in
476
+ its measured traces of real prints are the source of every threshold
477
+ in the phase interpreter.