Skip to content

MCP Server - Do Not Use This Software!! - #2614

Draft
tyeth wants to merge 89 commits into
Snapmaker:mainfrom
tyeth:startup/base
Draft

tyeth wants to merge 89 commits into
Snapmaker:mainfrom
tyeth:startup/base

Conversation

@tyeth

@tyeth tyeth commented Sep 7, 2026

Copy link
Copy Markdown

No warranty expressed or implied. Use at your own risk. Ensure the safety of yourself and others and property at all times, following local laws and safety requirements, use Personal Protective Equipment, and an Enclosure for the CNC.
You must always be next to the machine ready to perform an emergency stop or switch the power off.

tyeth and others added 30 commits August 29, 2026 20:15
Startup cost was unmeasurable: nothing between process start, server
ready and first paint carried a timestamp.

src/startup-timeline.js records named marks against one epoch. Main
stamps LUBAN_START_T0 into its environment; the forked server inherits
it and the renderer reads it off window.process, so marks from all three
processes are comparable. Each prints one ordered table at the end of
its own startup.

Lives at the top of src/ so every entry point reaches it with no build
change - src/*.js is babel-compiled for main, and both webpack bundles
resolve ../startup-timeline.

No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A literal newline had ended up inside the template literals. Same
output, but the escape is what was meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
index.jsx would not render until signin had returned and the socket had
emitted 'startup'. Neither is needed by anything on the home screen, and
controller.connect() has no timeout, so an unreachable backend left the
user on the spinner indefinitely.

Render once i18n is ready; run signin and connect afterwards. i18n keeps
a 3s cap so a stalled backend delays the paint by at most that.

socket-controller now buffers on/once/channel registrations made before
a socket exists and replays them on connect, because components mount
before connect() is called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API layer used origin-relative paths and socket-controller did
io.connect(''), both of which only work while the page is served by the
backend. #3 loads the app off disk over luban:// before the server
exists, so neither will hold.

backend-origin.js owns the answer. It defaults to window.location.origin
for an http(s) page, so this changes nothing today, and otherwise waits
for main to hand it over: ipcMain answers 'get-server-origin' on demand
and pushes 'server-origin' once the server is listening.

No behaviour change; plumbing for #3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the splash wait. The window loaded app/loading.html and only
navigated to http://127.0.0.1:<port> once the forked server reported
ready, so the user watched a GIF for however long the fork took - 1.6s
warm, 12-26s cold.

The luban:// handler already serves app/** off disk, so the window now
navigates straight there. The protocol registration, @electron/remote
and the proxy config move ahead of the first load into
prepareAppEnvironment(); startToBegin() keeps only bookkeeping.

That makes every API call cross-origin, so the server answers CORS for
luban:// origins - including the OPTIONS preflight superagent forces by
sending Authorization and Cache-Control - and socket.io gets the cors
option v4 requires.

Three things the move exposed:

- the scheme was registered without supportFetchAPI, so i18next's
  requests never reached the handler and always hit the 3s cap
- express mounts the app directory at both / and /worker, so worker URLs
  arrive with a prefix that is not on disk; the handler now strips it,
  and reports a missing file rather than returning a stream that throws
- components call the API as they mount, about a second before the
  server origin arrives, so those calls resolved against luban://.
  defaultAPIFactory now holds each call until the origin is known

First paint 3275ms -> 1503ms warm, and now precedes server-ready rather
than following it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #2.

DataStorage's constructor logs from module scope and createServer()
logs as its first statement, so the gap between those two lines is
pure require() of ./app and ./services. Across 15 launches of my own
log that gap was 12-24s cold and ~0.6s warm; a cold run of this
branch's parent measured 26.6s.

Nothing above the listener needs either module. The port now binds on
a bare http.createServer whose handler parks requests, the ready
callback fires immediately, and express, the machine channels, the
slicer and the task workers load on the next tick. Parked requests are
replayed once the app exists, so the gap refuses nothing.

DataStorage.init() moves into the deferred block too - it must run
after app.js, which installs the process-wide unhandledRejection
handler that its floating font download relies on.

The startup table prints at 'ready', which is now before all this, so
the deferred phase reports its own line.

Warm: bundle required 1081ms -> 369ms, server ready 1764ms -> 982ms,
services ready at 1711ms and off the critical path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #5.

Behind a VPN or offline the online-resource proxies never answered.
All five logged the error and returned without touching res, so the
renderer's XHR stayed open until the socket gave up. They now carry a
5s response / 10s deadline timeout and always reply, 503 on failure.
getUserInfoData also stops calling agent.use() per request, which
accumulated Authorization plugins on the shared agent and leaked
whichever token was set last into unrelated calls.

downloadManager had no timeout and no catch, so an unreachable font or
calibration map left a floating rejection. It now aborts at 15s and
resolves a boolean - callers treat these as best-effort.

whenBackendOrigin() was unbounded, noted as a known gap in #18; it now
rejects after 30s so API calls fail rather than queue for ever.

CaseResource ran an access probe with no timeout at all, leaving a
blank frame until its 60s iframe timer fired. Bounded at 2s, matching
the home page probe. It was also mounted unconditionally and merely
hidden with display-none, so every launch loaded an iframe from
resources.snapmaker.com even for users who never open the Library; it
now mounts on first open and stays mounted after.

The home page says so inline - an empty grid with a label above the
local examples - rather than popping a dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #21.

Sentry.init ran at module scope, before app.whenReady, on every launch:
21 integrations, the Electron crashReporter, four OpenTelemetry globals
and a read of its offline envelope store - all ahead of the first
window, and all useless to someone offline or behind a VPN. Nobody
opted in. It is now required and initialised only when the stored
enableCrashReporting flag is set, which defaults to false. debug: true
is dropped from the shipped config.

electron-updater was imported at module scope and updateHandle() wired
its listeners during startup, though nothing checks for updates until
the renderer asks 200ms after mount. The wiring moves into
wireAutoUpdater(), called on first use. node-fetch is likewise required
where the changelog is fetched, its only caller.

The flag is exposed as a Settings > General checkbox and a Settings
menu item, both stating it applies on next start, backed by
get/set-crash-reporting IPC. Main reads the store before any renderer
exists, so the store is authoritative.

Warm, same profile: app ready 497ms -> 76ms, first paint 1413ms ->
1055ms. Startup log drops from 106 to 69 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
machine.json held a single server.{name,address,token} triple, so
connecting to a second machine overwrote the first machine's token,
forcing a touchscreen re-auth on every switch even though tokens stay
valid until the machine powers off.

Persist server.machines as [{name, address, token, lastConnectedAt}]:
- lookup by address first, name as DHCP-reallocation fallback,
  most recent record wins on multiple matches
- upsert on successful connect, keyed by address
- legacy keys migrated on first read and still written after each
  connect for external readers and UI auto-select

Fixes #6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MCP Streamable HTTP endpoint (stateless JSON-RPC over POST /mcp) on its
own loopback-only HTTP server, off unless LUBAN_MCP_PORT or configstore
mcpPort is set. Hand-rolled transport: the official SDK needs Node 18+
and Electron 15 embeds Node 16. Tool registry plus one read-only seed
tool, get_connection_status, backed by a new ConnectionManager accessor.

Closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_machine_profile reports build volume, per-toolhead work ranges and
authored kinematics (SM2 family: platform travels in Y, toolhead moves
X/Z) - null for machines whose kinematics are not recorded, so an agent
cannot guess them. get_position reads the last heartbeat cached on
SstpHttpChannel and reports work and machine coordinates (machine =
work - originOffset, Luban's own convention), origin offset, homed
state and report age.

Closes #8. Closes #9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Motion leaves the process only as a gcode file through the same
prepare_print/start_print path as Start on Luban, so the controller job
state machine and door interlock apply (#23). submit_gcode_job stages a
file and returns a confirm_url; a human reviews extents, feeds, spindle
use and warnings in a browser and approving mints a one-time code that
is never returned over MCP, so a model cannot self-authorise motion.
start_gcode_job requires that code, an idle machine, and consumes the
token whether or not the start succeeds. stop_gcode_job needs no
confirmation. validate_gcode reports statically without state.

Closes #12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
capture_frame reads the local USB webcam through ffmpeg DirectShow
(mcpFfmpegPath/mcpCameraDevice) or an HTTP snapshot URL (mcpCameraUrl);
list_cameras enumerates sources. Every frame is stamped with the
firmware-reported position it was taken at. move_and_capture performs
one bounded XY move at the current Z via the direct path - no Z
parameter by design, Z and compound motion go through submit_gcode_job
per #23 - waits for two settled heartbeats at the target, then captures.
Guards: idle machine, toolhead off, per-call travel limit
(mcpMaxJogDistance), build-envelope check in machine coordinates.
The transport now passes tool-supplied MCP content through, so frames
return as image content rather than JSON text.

Closes #10. Closes #11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Calibrations are keyed by the machine Y they were derived at (the SM2
platform travels in Y, so a pixel-to-machine mapping is only valid at
that Y) and record Z since camera height changes scale; stored as a
2x2 pixel-delta-to-mm matrix in userDataDir, surviving restarts.
visual_servo executes one clamped correction step per call through the
same guarded single-move path as move_and_capture - the iteration loop
belongs to the calling agent - auto-selects the nearest calibration by
current Y, and warns when the step itself moves Y or the calibration is
off-key. Deriving the matrix stays the agent's job; the store keeps it.

Closes #13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verbose toggle on the console widget: heartbeat position and status are
printed on change (work and machine coordinates), and MCP tool calls are
mirrored live as one line each - name, duration, ok or error - via a new
mcp:activity broadcast from the server. Off by default; toggling prints
its own state so the mode is always visible in the scrollback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings gains an MCP Server section: enable toggle and port box,
persisted to the server configstore (mcpEnabled/mcpPort) and applied at
the next start, with a status line reporting what this run is actually
doing - listening address and tool count, or not running - and whether
LUBAN_MCP_PORT overrides the stored settings. Served by GET/POST
/api/mcp. Legacy behaviour kept: mcpPort alone still enables when the
mcpEnabled flag has never been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator-defined vocabulary: home/homing is ALWAYS machine home - G28
to the limit switches - never the work origin. Moving to work X0 Y0 is
the separate goto_work_origin operation: one bounded XY move at the
current Z through the same guarded path as move_and_capture, Z
deliberately untouched. Both tool descriptions now state the
distinction so calling agents cannot conflate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parked socket.io requests replay into Express once services start and
404 there, because socket.io only attaches its request interceptor at
startServices - so entering Workspace during the bind-to-ready gap
broke the page and every reload until services finished. A prompt 503
with Retry-After lets the client back off and retry into the working
server. Hotfix at the top of the MCP stack; belongs upstream in the
startup stack (#38) and drops out when fixed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hardware-observed on the A350: G28 invalidates the work offset while
the controller keeps reporting the pre-home originOffset in the
heartbeat, so derived machine coordinates land outside the build
volume (Z 656 on a 325mm machine) and work coordinates silently stop
meaning what they did. get_position now carries a warnings field that
flags out-of-volume machine coordinates and says why; the home tool
result points at it and tells the caller to re-establish the work
origin before trusting work coordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… home

Operator decoded the coordinate model from Luban's own Home button with
the verbose echo: the controller has G53 (machine workspace) and G54+
(workspaces), heartbeat pos is in the currently selected workspace, and
machine home is (-19, 342, 328). Luban homes as G53;G28;G54 - a bare
G28 leaves positions reported in an unselected workspace, which was the
source of the impossible derived machine coordinates (Y 464/Z 656).

- home now sends the same G53;G28;G54 sequence as Luban's button and
  warns that G28 also homes B (stock on the rotary rotates; observed
  -45 to 0 on hardware).
- Every direct-path gcode an MCP tool sends is mirrored to the verbose
  console with the controller reply ([mcp:home] > G28 / < X:...), so
  the operator can see which coordinate frame each command ran in.
- New query_firmware_position tool returns the raw M114 response
  alongside the heartbeat view - the authoritative frame check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
….json

The skill predates the tooling: it was written when every frame was
hand-pasted and gcode hand-relayed. Rewritten around the Luban MCP
surface (position-stamped capture, guarded single moves, Y/Z-keyed
calibration store, one-step visual_servo, M114 frame check, human-gated
jobs) and the machine semantics verified on hardware since: G53/G54
workspace model, home = (-19, 342, 328) via G53;G28;G54, origins
persist across homing, toolhead-mounted camera with the platform
moving under it in Y, rotary homes with G28. The vision core is
unchanged and board_metrology.py is bundled. The skill degrades
gracefully to the hand-relayed workflow when no MCP is live.

A project-scope .mcp.json at the repo root points sessions at the
local MCP endpoint (127.0.0.1:40889/mcp), so agents working in this
repo get the luban server without per-command --mcp-config flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HH:MM:SS.mmm on every verbose-mode line - sent-command echoes,
heartbeat position changes, MCP activity and gcode mirror - so the
operator can measure real latencies, e.g. the ~1s gap between a
commanded move and the heartbeat reporting the new position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Driven by a real submission: a job meant to 'drop Z 20mm' was written
as G90 + G1 Z-20 - an absolute move to work Z-20, not a distance, which
plunges below stock whenever the origin is at stock top. Three new
warnings: absolute Z below zero with spindle off (suggests the G91...
G90 relative wrap), motion before any G90/G91 (inherits whatever mode
the controller is in), and ending with G91 active (Luban's convention
restores G90). Report also carries assumesDistanceMode and
endsInRelativeMode. Verified against the real submission and four
variants; legitimate cut jobs are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hardware finding: the firmware parks back at the work origin when a
prepare_print/start_print job completes, so the interlocked file path
cannot hold a working Z - the only path that persists a Z change is the
direct one, which the operator rule reserves for explicitly requested,
carefully considered moves. move_z operationalises that rule: a SINGLE
absolute Z move (spindle off, homed, idle, envelope-checked, feed
capped at 600) staged through the same confirm page as jobs - which now
shows a DIRECT MOVE banner stating the door interlock does not apply -
with current Z, target, delta, feed and the agent's stated reason. Only
the operator's one-time code executes it; the result waits for two
stable heartbeats and reports the persisted position. Jobs gain a kind
field (file|direct) and a completed state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two live-session frame misreads - the calibration board dismissed as a
cutting mat, and the endmill never identified - drove a blind 80mm
descent past a usable view. The skill gains a frame-reading section:
identify by evidence not remembered composition (the board is the
yellow-brown surface with printed black grid and cell labels), the
rig-mounted-vs-scene heuristic (frame-position invariant across moves
means camera-mounted), and the endmill's near-lens blur signature.
capture_frame now reports the operator-configured expectedToolRegion
box (configstore mcpToolRegion) with every frame, turning tool
identification into a lookup on fixed rig geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A live sign-flipped calibration (M = -J^-1) drove two servo steps away
from the target and was caught only by manual re-measurement. The tool
contract held; the gap was no automatic safeguard. visual_servo now
remembers the last step per calibration+target and warns the moment the
pixel error fails to shrink - one wasted step instead of several. Both
tool descriptions state the convention outright (error = target -
feature; M = +J^-1; J.(M.e) must reproduce +e) and the skill's
derivation step now includes the sign verification protocol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dominant field error source was hand-estimated pixel coordinates:
a misread hole position caused a ~50% calibration error, and the sign
bug was only caught by manual cross-checking. Captures now carry a
frameId (last 12 frames cached in memory) and track_feature template-
matches a patch between two frames by id - zero-mean NCC on jpeg-js
(already a dependency), returning the matched pixel, shift, confidence,
and a second-peak gap that warns of repetitive-grid ambiguity. Verified
offline: a synthetic (17,-9) shift recovered exactly at NCC 0.92 in
211ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…amera, batch Z

Five fixes from the first full calibration session:
- set_camera_calibration accepts the fitted jacobian and REJECTS a
  sign-flipped matrix (M.J ~ -identity) before it reaches hardware;
  large identity residuals warn.
- visual_servo auto-select no longer hard-fails at 2mm - a single step
  drifts Y 2-6mm - it picks the nearest entry within 25mm and keeps the
  scale-may-be-off warning beyond 2mm.
- New set_tool_region tool so expectedToolRegion converges from live
  frames instead of config edits.
- Camera device choice is sticky (last-good remembered, preferred) with
  one retry on transient open failure; a vanished device is an error,
  never a silent substitution to another (possibly dead) camera.
- move_z accepts z_targets (max 20): the operator approves the exact
  list once, each start_gcode_job call executes one step with settling
  between, and the series can be abandoned anywhere - same safety
  property, one approval instead of N.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Observed live: a move_z to -95 returned completed while the heartbeat
still reported -85 - two identical post-issue beats can both predate
the motion because the heartbeat lags ~1s. Direct moves now parse the
absolute Z target from the executed gcode (G53 wrap selects the frame)
and the settle loop waits until the reported Z matches within 0.15mm;
home requires the position to move off its pre-G28 value at least once
before accepting stability (25s fallback when starting at home). On
timeout the position is returned with position_verified: false and a
warning naming query_firmware_position - never silently. XY moves
already verified against their target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every motion tool now takes wait_until_moved (default true: block until
the returned position is verifiably settled at the target). false
returns as soon as the controller accepts the command - useful during
the ~15-20s home or long Z descents - always with position_verified:
false and a poll-get_position note, never a silently stale number.
move_and_capture and goto_work_origin skip the frame too when not
waiting (it would not show the commanded position); visual_servo
deliberately has no such arg, since its contract is measure-after-step.
move_z stages the preference on the job; start_gcode_job can override
per call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tyeth and others added 5 commits September 6, 2026 20:24
… keep-out, groups, procedure stop, stock outline

probe_program can now survey freshly clamped stock of unknown size from the jig
geometry alone, in one approval (work plan: docs/NEW_STOCK_SURVEY_TODO.md):
two-operand references (mid/diff/min/max, path-valued plus/minus, any depth),
rotary axis + probe geometry stored through the MCP tool set_probe_geometry
(never a prerequisite; no stock size stored - rotate_b takes a per-program
swept_radius_mm) and seeded as the `axis` namespace, keep-out checks of every
hop/column/march at staging and at reference resolution (stored landmarks are
CROSSING obstacles - probing inside the rotary landmark is the job, marches
and traverse-height hops are exempt; program keep_out boxes are volumes),
summary highestAt/lowestAt, a circle expected_profile for cylinders, `group`
expansion over B angles, a derived section (thickness, centring, width, yaw,
end slope) on the result, and an event-budget refusal.

probe_stock_outline (also op kind stock_outline): top at several points with
hole rejection, sides marched from an over-extended estimate at top - depth,
skipping along each face at a standoff with a dynamic march start, fit to
centre (machine + work), size (+ tip), yaw. march.ts: shared march and the
stepped touch-probing traverse (retreat on contact, capped); surface scans
gain hop_mode stepped.

stop_gcode_job now stops PROCEDURES (job 5ad5fcce6b3a: three stops answered
ok:false while the scan kept stepping): cooperative stop request checked by
every motion primitive, ProcedureStopped at the next step boundary, raise,
state `stopped`, partial results (ProcedureAbort.partial) stored on the job
record for every abort. probe_sequence marches take on_miss (default
continue): a miss records no_contact instead of aborting the sequence.

27 unit checks; tsc and eslint clean; hardware pending.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e, returns an inspection report

Fusion 360 / FreeCAD / any Grbl-Marlin post (or a hand-written file) can now
supply the probing toolpath as gcode. The server parses and translates it
(probeGcode.ts pure parser/simulator, probeCam.ts planner/runner) instead of
sending it raw - the firmware compiles G38 in but on the 3DP probe input, not
the CNC touch probe: G38.2/G38.3 become the shared sensor-gated march to the
programmed target (the travel limit; retreat to the cycle start; G38.2 without
contact aborts per Grbl unless on_miss continue), G38.4/G38.5 a probe-away
until release, G0/G1 links follow law 2 (raise to the traverse height + guarded
segmented descent, or a stepped touch-probing traverse), a bare G0 B<angle> is
a 3+2 station (raise first, then a verified rotate_b), G4 dwells; G90/G91,
G53 (absolute even under G91), G20/G21 honoured, feeds ignored, Grbl $ lines
skipped; M3/M4, M0/M1, M6, G28, G92, arcs, macros, O-words, B with XYZ,
incremental B and A/C refused with the line number. (PROBE ...) comments carry
id/name/group/role/feature, nominal, normal, signed tolerances, surface offset
and feature size/centre/tolerances; (RESULTS ...) the document/toolpath
identity.

The inspection report (inspectionReport.ts) is stored as JSON and rendered as
Fusion 360 inspection results in the shape Autodesk's own result generator
writes (START/RESULTSFILE/DOCUMENTID/MODELVERSION/TIMESTAMP, TOOLPATHID/
TOOLPATH, G331/G330 with the station's B, G800 nominal with O = surface
offset and signed L, G801 tip-centre + R, END), as the Renishaw Inspection
Plus printout Fusion imports for Probe WCS / Probe Geometry results (features
reduced from group/role metadata, SIZE/POSN/OUT OF TOL/OUT OF POS), CSV or
Grbl [PRB:] lines; deviations are of the surface (tip centre minus one tip
radius along the normal).

docs/FUSION_POST_REVIEW.md records the Opus review of the Snapmaker Fusion
posts (none supports probing; the 4-axis post is an unmodified Fanuc post);
docs/post/snapmaker-probing.cps is a probing-capable post written from scratch
to its outline (unverified in Fusion); docs/CAM_TEST_CATALOGUE.md and
docs/examples/cam-tests/ carry the survey of existing test material and the
fixtures that drive the parser suite. 9 + 6 + 4 unit checks; tsc and eslint
clean; hardware pending.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion prediction on the plan

Operator idea 2026-09-07: a low-fidelity game-like scene of the A350 with the
rotary and tailstock (operator-supplied CAD -> glTF with articulated nodes)
that the MCP fills with verified objects from probe results and landmarks,
ghosts for estimates, and that plays every staged procedure's motion list to
predict the first collision before approval (verdict + frame on the confirm
page). Architecture, coordinates/kinematics, scene object model, collision
rules (intended contact vs fault, rotations, coverage), MCP tools, phases,
operator inputs, risks. Specification only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Docs: Machine simulator specification - scene with provenance, collision prediction on the plan
Fix: Linux camera capture (v4l2), Ubuntu 24.04 AppArmor launch crash, mac fork builds
@tyeth tyeth changed the title MCP Server MCP Server - Do Not Use This Software!! Sep 7, 2026
tyeth and others added 24 commits September 7, 2026 16:50
… /mcp

Claude Code / Claude Desktop run the MCP authorization flow against an
`http` server before the first JSON-RPC call and refused to connect when
the steps 404'd ("Dynamic Client Registration rejected (HTTP 404)").
oauth.ts answers every step - protected-resource and authorization-server
metadata (both well-known paths, with and without the /mcp suffix, plus
the openid-configuration alias), POST /register (RFC 7591), GET /authorize
(code + PKCE S256, redirects straight back: no login page, the confirm
pages stay the human decision points), POST /token (code and refresh
grants, rotation). It authenticates nobody: /mcp never checks tokens and
never 401s, so clients without the flow and tokens from before a restart
keep working. Redirect URIs must be loopback http(s) or a private scheme.

What the flow buys is attribution: the registered client_name labels
every `tool ...` log line and the initialize line.

Also fixes LAN mode: McpServer.handleRequest hard-coded a loopback
re-check, so a same-subnet client the index.ts gate had admitted still
got `403 loopback only` on /mcp. Both gates now share isTrustedAddress /
isTrustedOrigin with the mcpAllowLan setting. Every OAuth route sits
behind that same gate.

Verified: tsc (services/mcp) and eslint clean; a 41-check harness drives
the real router through discovery, registration, authorize, PKCE
mismatch, code reuse, token, refresh rotation, and /mcp with, without and
with a stale bearer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/TOOLS.md: one line per tool with what it does and when to reach for
it, grouped by area (orientation, gcode jobs, direct motion, camera and
vision, landmarks, probe feed, tool setter, touch-probe procedures, CAM
probing), closing with the standing operator rules the tools assume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Inspect cycles

First Fusion runs of docs/post/snapmaker-probing.cps (Inspect Surface, program
1001, Fusion CAM 2705.1.11, post engine 5.413.5), fixed from the post log and
the first complete output:

- onCommand swallows COMMAND_PROBE_ON / COMMAND_PROBE_OFF, which Fusion
  issues around every Probe / Inspect Surface operation ("Unsupported
  probe-on command ... onCommand() for record 400" killed the post). The MCP
  arms its probe feed on the operator's approval, so nothing is written.
- Rotations and traverse raises always carry their G0 word: the raise made
  G0 modal, so gMotionModal.format(0) emitted a bare "B0." / "G53 Z320.".
- Linking rapids between cycles are no longer echoed: each was turned into a
  raise to the traverse height, three raises per point in the output. Every
  probe writes its own law-2 approach and the MCP retreats to it itself.
- Inspect Surface targets follow Fusion's probing direction. Fusion's middle
  cycle point is already the END of its probing move (nominal plus its own
  overtravel) along the direction it chose, which for a chamfer is an axis,
  not the surface normal. The post pushed that point a further 15 mm along
  -normal, bending the G38.2 path and contacting elsewhere. Now the target
  is Fusion's end point, extended along the same direction only until
  minOvertravel lies past the nominal.
- Inspect Surface no longer relies on getNumberOfCyclePoints /
  isFirstCyclePoint / isLastCyclePoint: points are buffered per cycle and
  emitted from onCycleEnd in groups of three (approach, measure, retract),
  two, or a lone measure with a synthesised approach.
- getCurrentCyclePointIndex is tested with typeof (bare reference to an
  undefined kernel helper is a ReferenceError).

Posted successfully in Fusion after the probe-on fix; the direction and
linking fixes come from reading that output and still need a re-post.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Feature: OAuth/DCR shim so Claude Code connects; LAN gate honoured on /mcp
Docs: Terse per-tool reference for the 47-tool MCP surface
Fix: Fusion probing post - probe on/off commands, G0 on B rotations, Inspect cycles
…C skills

New .claude/skills/cnc-motion-rules/SKILL.md is the single home for the seven
motion laws, the coordinate doctrine (machine coordinates for planning; the
work origin is the operator's; G90/G91 is distance mode, not a frame; every
staged job declares its frame), the get_position reliability states, the
sanctioned exceptions and a before-any-motion checklist. Written after the
2026-09-12 work-frame G0 Z0 job reached the confirm page with no warning.

cnc-probing, cnc-visual-alignment and tool-change now point at it instead of
carrying their own copies, and three stale facts found by the memory audit
are corrected: heartbeat period is 2 s (not ~1 s), the probe length is read
from get_stored_state (71.1 was pre-crash), and work origins do not survive
a machine reboot. .claude/skills/README.md indexes the four skills.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ork frame

An agent-authored gcode job now has to say which coordinate frame it runs
in before it reaches the confirm page. validateGcode tracks G53 (own-line,
sticky on this controller) and G54..G59 per line and reports the frame in
force at the first motion line; resolveJobFrame settles it against the new
submit_gcode_job `frame` argument: G53 in the file = machine; G54+ in the
file or frame:"work" = work (a Luban/slicer export selects no workspace and
is accepted unmodified); frame:"machine" without a literal G53 is refused;
nothing at all is refused with the rule quoted back. Inline "G53 G0 ..." is
flagged - the firmware does not honour a one-shot G53. G92, mixed frames,
out-of-travel Z (in either frame, resolved through the live offset) and a
work-frame absolute Z0 become loud warnings. The confirm page gains a Frame
row and machine-resolved Z extents.

Every MCP emitter declares too: move_z / move_and_capture work branches
send an explicit G54, and the probeCam / probeOutline / probeProgram
previews carry G53 like the other planners. The wire pattern G53...G54 is
unchanged (operator decision 2026-09-14).

First unit tests for the pure MCP modules: `npm run test:mcp` (ts-node,
node:assert, no framework) with 15 validator cases including the
2026-09-12 "G90 / G0 Z0" transit job, now refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ent beats

One judged machine-frame position replaces every hand-derived
`work - originOffset`. New pure module machinePosition.ts judges each
distinct heartbeat: a controller echo (position of record) outranks it;
a beat whose derived machine value is more than 50 mm outside the travel,
or whose raw fields jumped by exactly the offset (frame flip), or that
arrived before any offset was reported, is REJECTED - the last accepted
position is held with `reliability: awaiting-resync` until a coherent beat
rectifies it (operator law 2026-09-14: such a reading is a mistake, never a
position; ignore it, do not reinterpret it). Nothing is assumed.

getPositionSnapshot now returns the judged `machine` plus `reliability`,
`frame`, `reasons` and the rejected beat's `derived` value for diagnostics.
assertFreshHeartbeat - already in front of every procedure start, direct
move, job start and Z staging - refuses on awaiting-resync/stale, so a Z 555
artefact can no longer pass the traverse-height guard or a landmark
clearance check; survey_bed and the tool-setter tools gate explicitly. The
state (cached offset, zero-streak, previous raw, last accepted, trusted
offset, echo record) is consolidated and forgotten on every (re)connection,
because work origins die on a machine reboot. The gcode sequence counter
moves into positionOfRecord.ts so tools/machine.ts can read the echo record
without importing the channel code.

The Workspace console no longer prints its own unguarded subtraction (the
">500" lines): Marlin:state shows the raw report, and a new mcp:position
event carries the judged machine position and its reliability once per
beat (UI-only broadcast, not recorded on the job). get_mcp_diagnostics
reports rejected beats by reason, resyncs and disconnects.

13 unit tests cover the judge, including the recorded incidents (Z 555.7 /
Z 656 artefacts, the zero-offset streak, the return from a G53 window).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mcpSafeTraverseZ now defaults to 328 (= home Z on the A350) instead of 320
(operator decision 2026-09-14). The crossing-landmark exemption for
segments at or above the traverse height is removed from checkMotion: it
existed because the rotary-axis landmark's clearance (328) sat above the
old traverse height, and it let a traverse cross the rotary box - tailstock
included, height unmeasured - with 8 mm of unverified headroom. At 328 a hop
passes every stored clearance on its own merits; a hop below 328 (surface
scan, stepped link) is checked like any low segment; marches stay exempt
(they stop on contact); volumes refuse at any Z below their clearance.

get_stored_state.limits reports safeTraverseZMm. README paragraph on job
34d787bdb2d7 rewritten to record the decision. Six envelopeChecks unit
tests pin the behaviour (34 tests total).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
README gains a "Coordinate frames and the position of record" section
(frames on this controller, machine-coordinates doctrine, the reliability
table and the judge's rules), a Safety-model bullet for the staging frame
handshake, the missing modules in the file-stack listing (envelopeChecks,
positionOfRecord, machinePosition, probeGcode, inspectionReport,
programRefs, probeOutline, probeProgram, landmarks, diagnostics,
jobTiming, tests/), `npm run test:mcp` in the pre-commit gate and the
corrected tool count (47). TOOLS.md documents the `frame` argument on
submit_gcode_job, get_position's reliability fields, the rejected-beat
counters and the standing rules (machine coordinates, traverse Z328, the
work origin is the operator's, canonical guidance in cnc-motion-rules).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s pair

apply_tool_length_offset - the one sanctioned work-origin write - now
refuses a stored measurement pair that is out of order or predates the
machine's last (re)connection (work origins die on a reboot) unless
old/new trigger Zs are passed explicitly; requires a reliable position of
record; and its confirm page spells out what the touchscreen wizard would:
the toolhead machine Z that does not move, work Z before/after, the
work-origin Z offset before/after and where work Z0 lands on the machine
scale. The generic G92 warning is replaced on this page by the plain
statement that this IS the sanctioned path. machinePositionDiagnostics
reports resetAt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ged like move_z

The 100 mm move_and_capture cap kept forcing transport into hand-written
file jobs - which is how a frameless "G0 Z0" got staged on 2026-09-12. The
new traverse_xy tool is the transport twin of move_z: an absolute XY target
or an ordered series (max 20) at the traverse height, ONE operator approval
on the confirm page, one start_gcode_job call per leg on the direct path so
the position persists. Refused unless the toolhead is already at or above
mcpSafeTraverseZ (328) - raise with move_z first; there is deliberately no
override. Every leg is checked against the stored landmarks (checkMotion)
and every target against the travel; Z is never written; default frame
MACHINE with G53 declared on every step (work-frame steps declare G54). The
planner is a pure module (traversePlan.ts, 10 unit tests, 44 total) and the
confirm header states frame, current position, Z-unchanged, every leg with
its machine coordinates and distance, total travel and the sensor note.

The direct-move settle check now verifies whichever of X/Y/Z the executed
G1 line names (parseDirectTarget) instead of Z only. TOOLS.md, README (48
tools) and the cnc-motion-rules / cnc-visual-alignment skills point at
traverse_xy for transport.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live on the box after the 328 change: home reports machine Z
327.9989959716797, and every "at or above the traverse height" test was
exact, so traverse_xy refused the very first transit from home ("327.999
below 328") and move_and_capture / survey_bed / the tool-setter travel
would have too. TRAVERSE_Z_TOLERANCE_MM = 0.05 (well inside the heartbeat's
resolution, unarguably top gantry height) now applies at all four
comparisons; the traverse planner plans its segments at the traverse
height when within tolerance so the rotary landmark (clearance 328) cannot
refuse a 1 um shortfall either. Regression test pins 327.9989959716797
accepted and 327.9 refused (45 tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stead of gating

Two things the operator saw on live confirm pages (2026-09-14):

- probe_point / probe_vector anchored their plan at the heartbeat's raw Z
  (327.999 for a 328 home), so the preview and the runner issued "retreat
  to 327.999" followed by "finish at 328.000" - two commands one micron
  apart, with heartbeat noise in a commanded position. A start Z within
  TRAVERSE_Z_TOLERANCE_MM of the traverse height is now the traverse
  height: one retreat to 328.000, no second line, and the travel limit is
  computed from the snapped start.

- probe_surface_path refused more than 60 stations. A station count is a
  time and event budget, not a safety line: the path ceiling is now 400
  (the grid's), and above 60 stations the confirm preview carries a
  WARNING with the estimated duration and job-event count. The operator
  law caps (z_safe_delta 20, hop 60, coarse step 1) are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live on 2026-09-14 (job 885cb85b5f32): a 60-station surface scan stopped by
stop_gcode_job surfaced the raw stop message with result: null - 52
measured stations were only recoverable from the event log. The runner's
abort path tested `err instanceof ProcedureAbort`, which is false for an
Error subclass once the prototype chain is lost in a down-levelled build,
so the partial result it had built was never attached. ProcedureAbort /
ProcedureStopped now live in a pure module with a marker property, an
explicit prototype and isProcedureAbort / isProcedureStopped helpers, and
every runner tests the marker. start_gcode_job stores the partial result on
any abort, with the ending beside it.

Every job now carries a structured `ending` (kind, reason, when, how many
stations/ops were measured): completed, stopped-by-agent,
stopped-by-operator, withdrawn, rejected-by-operator, crash-alarm,
overtravel-alarm, unexpected-contact, controller-rejected, timeout,
operation-failure, machine-stopped, completion-unverified - classified in
the pure jobEnding.ts. File jobs log each pause (door interlock or operator
pause) and say so in their ending; describe(), get_gcode_job_status and the
stop_gcode_job note all report it. 6 new tests (51 total), including the
lost-prototype case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n plans)

Eight operator-realistic evals (the tailstock scan, the headstock X profile
with unknown Z, a transit from home, running a Luban export, a tool change,
a rejected heartbeat, stock flatness with unknown height, "don't bother me
with confirmations") were planned by fresh Sonnet, Opus and Haiku agents
with no memory, reading only the four skills and TOOLS.md, and graded on
lawfulness AND operator time. Pass rates: Opus 99 %, Sonnet 96 %, Haiku
85 %, no-skill Sonnet 47 %. The critiques converged; this applies them:

cnc-motion-rules: checklist first with "ask once" and a transit fast path;
law 1 resolved against law 6 (a named procedure authorises STAGING, the
page is the decision point, a program is one decision point); below-328
transits ask for the Z lift; chat-stated landmarks and clearance == 328;
law 6 names every motion tool, wait_for_approval_ms, confirm-URL delivery
and the "no confirmations" answer; law 7 allows file jobs for programs;
work-frame case first in the handshake; operator-stated probe length =
which probe, not the value; resync is passive with re-read thresholds;
new S7 "Running a program someone else generated" and S8 canonical calls
with the real argument schemas (traverse_xy, move_z, submit/start/status,
run_tool_setter, the two-op find-then-scan probe_program and the reference
grammar); incidents moved to an appendix.

cnc-probing: 407 -> 191 lines. Laws are a pointer; leads with find-then-
scan; unknown-Z route and its cost; measuring inside an unmeasured region
(retiring a keep-out); op-selection table (probe_circle is vertical-axis
only); spacing fencepost; hop_mode by spacing x slope; event budget at the
point of decision; reading a profile (symmetry centre, not highestAt);
bit_length_mm is a protrusion. CAM probing moved to references/, the bed
survey moved to cnc-visual-alignment, which gains the viewing-pose
arithmetic. TOOLS.md: `home` no longer claims to clear stale position
state; argument lists on the job and motion tools.

evals/evals.json (8 prompts, assertions) and evals/REVIEW-2026-09-14.md
record the method, numbers, findings and the eval-set critique; run
outputs live under .claude/skill-evals/ (ignored).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Iteration 2 rerun on Opus / Sonnet / Haiku against commit d9d466b: 99.0 / 96.8 / 83.4 %
(iteration 1: 99.0 / 95.9 / 84.6). Schema guessing and estimate-as-start-Z are gone; Sonnet is
at the minimum approval count on every eval. Review with per-eval tables and the ranked
remaining costs: evals/REVIEW-2026-09-14-iteration-2.md.

Skill edits (iteration 3), each tied to a graded failure:
- Staging is half a call: §0 item 6 and every §8 canonical call show the staging tool with its
  start_gcode_job (Haiku omitted the start call in 4 of 7 motion evals).
- Probe length: the store is normally set; a measurement is planned only after READING an empty
  store, never budgeted pre-emptively (Opus added a conditional approval on evals 1, 6, 7).
- Law 6 loses the confirm_token aside (hedged by Sonnet in iteration 1, Opus in iteration 2).
- §8 gains canonical set_probe_geometry and apply_tool_length_offset calls (guessed by every
  model) and a surface_grid op with the chuck-jaw keep-out note; tool-change step 5 shows the
  real call; probing skill names size_x_mm/size_y_mm.
- §0: landmark test is against the SEGMENT, not the destination; a question asked is a question
  waited for.

Eval set: [WAIT]-before-consumption and stage-then-start assertions on the motion evals, a
jaw keep-out assertion on eval 6, a documented-shape assertion on eval 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reran the eight evals on Opus / Sonnet / Haiku against the iteration-3 skills (fbeb8c0):
100.0 / 96.9 / 92.1 % (iteration 2: 99.0 / 96.8 / 83.4). Every staging call in all 24 plans is
now followed by start_gcode_job; Opus's phantom probe-measurement approval and confirm_token
hedge are gone; no model guessed a tool argument shape. Haiku's remaining misses (stages before
waiting for its own question; reuses stand-in numbers; dropped expected_profile.circle on a
cross-axis scan) and the iteration-4 candidates are in
evals/REVIEW-2026-09-14-iteration-3.md.

Eval set: the physical-height assertion on the cutting evals (3, 4) now names the object in the
spindle (fitted tool after a change, probe during probing) instead of "the probe length", which
rewarded the wrong subtraction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: Frame handshake, machine position of record, canonical motion-rules skill
Job fd7fa6cb6396 (2026-09-16): run_tool_setter aborted BEFORE its travel -
the deployed build's traverse check read the post-home heartbeat Z 327.999 as
below 328 - and the unconditional "retreat to start height" then sent
G1 Z205.500 from Z 328 at the home XY (174.5, 340): a 122 mm plunge inside the
rotary landmark, with no XY move ever issued.

Operator law: an abort retreats STRAIGHT UP to the traverse height, for every
MCP procedure, never to a start height. New shared abortRaiseToTop
(probing.ts): no motion on an overtravel trip, hold while a probe still reads
contact, nothing sent when already at the top (float tolerance), otherwise one
Z-only G53 move to mcpSafeTraverseZ. All eight procedures' abort paths use it;
the along-axis "back to the start" legs of probe_point / probe_vector are
skipped when the start is below the head (mayDescend). Pure decision
planAbortRaise + mayDescend in traversePlan.ts with unit tests; confirm-page
texts, README law 9 and the cnc-motion-rules skill (law 8, appendix A) updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: Aborted procedures retreat straight up to the traverse height
A completed run_tool_setter retreated to plan.startZ (trigger + longest-bit
delta + clearance, ~235 on the rig) and left the head there, below the
traverse height every following XY move must start from (cnc-motion-rules
law 2). PR #90 fixed the ABORT path; this makes the SUCCESS path end the
same way: a Z-only G53 raise straight up to mcpSafeTraverseZ, nothing sent
if the head is already there (float tolerance), never back to the start
height. stay_at_trigger (touchscreen manual-swap wizard) is unchanged: the
tip is held in contact, no retreat at all.

- probing.ts: factor the shared `raiseToTop` (trip = no motion, optional
  hold-if-triggered, skip at the top, else one Z-only move; returns the
  outcome and final Z); `abortRaiseToTop` is now a wrapper that uses the
  abort-* phase names and clears expected contact before the raise, exactly
  as before. The success-path lift keeps the setter channels expected (it
  starts in contact), as the old retreat did.
- traversePlan.ts: `planAbortRaise` -> `planRaiseToTop` (same decision, both
  paths); new pure `planToolSetterEnd` (hold / raise / skip) drives the
  confirm-page end line.
- toolSetter.ts: plan.endZ, Phase 6 via raiseToTop with `retreated` /
  `retreat-skipped` phases, result.finalZ reports where the head was left,
  header comment and confirm page say so.
- tools/toolsetter.ts: description + plan.end_z.
- Tests: three #91 cases (below top raises to 328; at top sends nothing;
  stay_at_trigger holds) - 57 passing.
- Docs: README law 9, docs/TOOLS.md, skills cnc-motion-rules (law 8),
  tool-change, cnc-probing.

Closes #91

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: Tool setter ends at the traverse height, not the start height
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant