Skip to main content

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.

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 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:

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:

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.

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.

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.

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.

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

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

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

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:

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

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

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:

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.

Metadata

Release files for mcprusa 2026.10.13.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcprusa 2026.10.13.2
File Size Uploaded
mcprusa-2026.10.13.2.tar.gz 199.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcprusa 2026.10.13.2
File Interpreter ABI Platform
mcprusa-2026.10.13.2-py3-none-any.whl Python 3 none any Details

Total release size: 285.0 kB

Release files / mcprusa-2026.10.13.2.tar.gz

Download URL mcprusa-2026.10.13.2.tar.gz
Size 199.6 kB
Tags Source
SHA-256 checksum
How to use checksums
232080d90b039a416c938746742bab16013ebf7ffd61849a61fecc862c21632e
BLAKE2b-256 checksum
How to use checksums
75e8676ce8208e580f1360d99b6c005e43ce2c5e98af19a22afece1015a05af8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / mcprusa-2026.10.13.2-py3-none-any.whl

Download URL mcprusa-2026.10.13.2-py3-none-any.whl
Size 85.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6ebc425903b729326711c0bf125566a5822004390c58e6e9e6a5bbecf0bc3f9c
BLAKE2b-256 checksum
How to use checksums
67969c2dc0d5087996f7f3bdc89d766f91ec5e84ffb2c31da624b8456529219d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page