MCP server
soldermask mcp speaks the Model Context Protocol on its stdin and stdout, so a model that is not this pipeline's — Claude Code, Claude Desktop, Cursor, an Agent SDK loop, anybody's own harness — can drive the build stage by stage, hand in its own answer at any stage, and have every answer judged by the same judge the app and the evaluator use.
soldermask mcp # serve on stdin/stdout
soldermask mcp --tools # print the tool list as JSON and stop
soldermask mcp --model-calls # let the resolver ask this tree's own models (off by default)
SOLDERMASK_MCP_LOG=calls.jsonl soldermask mcp # one JSON line a tool callRegister it the way the client registers any stdio server. For Claude Code:
claude mcp add soldermask -- soldermask mcpFor Claude Desktop, Cursor and the rest, the same command and arguments in their mcpServers block:
{ "mcpServers": { "soldermask": { "command": "soldermask", "args": ["mcp"] } } }The server reads the same environment the command line does — SOLDERMASK_PROJECTS for where versions are saved, SOLDERMASK_CACHE — and it is the one place to set them, because a client starts the server with its own environment. It needs no model key: it calls no model. See Configuration.
For a client that is not on this machine, the same server runs over HTTP with a key — see Serving it to others.
The idea
Interoperability is at the stages, and the judge does not move. Each tool either runs this pipeline's method or takes the caller's, and every result carries the same verdict, gaps and notes a build from the app or the command line does.
The server calls no model; the client is the designer. Until 16 Sep 2026 one tool did — design, a prompt through this tree's backends, Gemini by default — and a client that took its draft and rewired it had a board that was two models' work under one name: the first real client's boards were built that way, and a replay of the evaluator's 59 designs through the tools would have measured Gemini's drafts under Claude's pins. So design went, design_brief hands over what the design stage would have asked its own model, and build takes a design and no longer a bare prompt. Whatever drives the server makes every model call in the build itself, which is what makes an agent-driven number attributable to the agent.
And nothing inside a stage calls one either, which from 21 Sep to 24 Sep 2026 was not so. The datasheet stage went into resolve() on 21 Sep: a vendor part's pin table is transcribed by the design stage's model when the cache does not hold it, and a pin name the symbol refuses is put to Jev. Every tool that resolves — search_parts (its chosen), check_design, build, rebuild — reached both, while status said model_calls: none; the tests could not see it, because they stub the backends to raise and the stage swallows whatever it raises. So the promise is now a switch (config.model_calls(), the environment's SOLDERMASK_MODEL_CALLS) which soldermask mcp sets to none when it starts, over either transport: a pin table not on disk is not transcribed, fetched or cached, Jev is not asked and a stored Jev answer is not read, and a design backend refuses before it is reached. A pin table transcribed earlier is data and is still read, like the catalogue; status says how many are on disk. --model-calls lifts the switch, and status and the server's instructions then say what may be asked. The 16 Sep run of 54 of 60 predates the datasheet stage, so it had neither.
A drive can be counted afterwards. SOLDERMASK_MCP_LOG names a file that gets one JSON line a tool call — the tool, the arguments as the client sent them, the text sent back, the seconds and whether it was an error — so the tokens each tool cost a drive and the time each board took can be read off a run, which the wire alone never kept.
| Stage | Ours | Theirs |
|---|---|---|
| Design | — (the server calls no model) | design_brief hands over the design stage's own system prompt, the accepted schema and the constraints the prompt carries; the caller writes the design.json, held to the schema by check_design |
| Parts | the resolver: a search on each component becomes a part, or a gap | search_parts shows the shelf the resolver would pick from and what it would pick; pins says what a part's pins are called; a component carrying lcsc is ordered as chosen and never searched for again |
| Placement | place — the precedent, six anneals, the routability model's pick | score_placement rates a placement with nothing routed; build and rebuild take one as placement |
| Routing | build — the A* router or freerouting, as rules.router | hand copper in constraints.tracks and constraints.vias, which the router keeps and routes around |
| Judging | always ours: workspace.verdict(), kicad-cli's DRC, the reviewer's rubric, the routability model with its provenance | — |
A placement is the genome's six facts with their names on — {ref: {x, y, rot, side}} — and not the genome IR itself: a bit-packed word is for a corpus that has to be a tensor, and a model on the other end of a JSON-RPC pipe cannot read one, nor the package index into this tree's vocabulary it carries.
The server is written by hand rather than on the mcp package — the subset a tool server needs is a few hundred lines, and the base dependencies stay six. The HTTP transport is the same server behind soldermask mcp --http (below); the app's own HTTP API is a different door, for the app's clients.
Tools
Every tool returns JSON as text and the same object as structuredContent. A result with ok: false and an errors list is a refusal the model can read — a design that misses the schema, a placement short of a part — and is marked isError. A tool that could not run at all (no such project, no kicad-cli) is a tool error with one line saying why. Arguments are checked against each tool's inputSchema before it runs.
| Tool | What it does | Runs |
|---|---|---|
status | which routers and judges are installed, the shipped routability model with its training date and held-out numbers (training-day figures, not a fresh evaluation), the catalogue on disk, where projects go, and model_calls (none, with the datasheet pin tables on disk it reads as data, unless started with --model-calls). Call it first. | nothing |
design_brief | {prompt} → what design.generate() would have sent its own model, for the caller to do instead: system (the design stage's system prompt — the packages this tree can draw, the measured idioms from the corpus), schema (the accepted design schema, with lcsc and constraints), constraints (what the prompt itself says about the board, read the way a build reads it — pass it to build as its own argument), and then. Write the design, put what could not be done in unsupported, and hand it to check_design. | nothing |
search_parts | {query?, kind?, value?, package?, limit?, in_stock?, format?} → the catalogue as rows to choose between: LCSC code, manufacturer part, description, package, price, stock, pad count, and footprint — what this pipeline can draw for it ("" means the pads come from the vendor's drawing for that exact code, package FROM_PART). With a kind, parts is the shelf the resolver's own ladder finds for a component written that way (resolve.candidates_for), in the order it would order them, and chosen is what a build would put on the board for it — resolve() run on a one-part design, with its gaps and notes — plus component, the design line to paste with the code pinned. Without a kind, the palette's text search and nothing chosen. A resistor, capacitor, inductor or crystal is best asked by kind + value (+ package), which the parametric index answers exactly. | the ladder, and one resolve |
pins | {lcsc, kind?, value?} → what the part's pins are called once ordered — pins[].pin is the string a net's connection uses — with the footprint and symbol they come from, every caveat, and component, the design line with the code pinned. resolve() run on a one-part design (resolve.pinout). | one resolve |
check_design | {design, constraints?, pins?, simulate?} → the design held to DESIGN_SCHEMA by field and path (every fault at once), its parts resolved against the catalogue (each ref to an LCSC code, price and stock, or a gap), the netlist ERC'd, with pins the names every resolved part's pins answer to, and with simulate ngspice on it. Nothing placed. | resolve, ERC |
place | {design, constraints?, seed?, candidates?, heat?} → this pipeline's placement: every candidate's rating, the model's pick, placement_report (wirelength cost, worst courtyard overlap, off-board distance), the geometry rubric, the placement notes, the precedents. Parts in constraints.pinned stay put. heat adds the model's 32×32 picture of where copper will crowd. Nothing routed. | one realise(), no fab output |
score_placement | {design, placement, constraints?, heat?} → the same report for a placement of the caller's, every part of it, the coordinates untouched. Seconds; the loop to iterate a placer in. A placement short of a part is refused and the refusal says to pin instead. | one realise(), no fab output |
precedent | {design, limit?} → the corpus boards that share the most parts with this design, and the best match's placement keyed by this design's refs in that board's own frame, whether or not it clears the bar the pipeline seeds an anneal from (pipeline_would_seed_from_it says). | the corpus index |
build | {design, prompt?, project?, placement?, constraints?, router?, seed?, pour?, precedent?, min_track_mm?, route?, simulate?, quantities?, note?} → the whole sequence saved as a version: resolve, ERC, simulate, place (the caller's placement pinned when given, this pipeline's otherwise), route, pour, DRC, rubric, Gerbers, BOM, CPL, KiCad file, price. Returns the verdict, the routing report, the files and where they are. The project is made when it does not exist. prompt is what the board was asked for, kept with the version; a prompt without a design builds nothing, because the server calls no model. Into a project that already has a block in force, a constraints argument replaces it (a decision made now, as PUT /constraints then rebuild is in the app); a block inside the design is carried and set aside for the project's, with a note saying so. | one realise() + commit() |
rebuild | {project, version?, placement?, constraints?, knobs…, route_only?, route?, note?} → the version's own design built again as a new version, against the constraints as they stand plus what this call adds. route_only keeps the version's placement and runs the router once on it. | one realise() + commit() |
verdict | {project, version?} → what the stored board says: the verdict and why, the routing report with the copper counted off the stored lists (wires, vias, length), the rubric, ERC, gaps, notes, the files. | nothing |
drc | {project, version?, dry_run?} → kicad-cli's DRC today on the version's KiCad file with its zones filled, and the verdict rewritten where the answer moved — projects rejudge for one version. | kicad-cli |
projects | {archived?} → every project with its version count and whether the current one passed. | nothing |
Two keys beyond the schema. DESIGN_SCHEMA is closed — every object refuses a field it does not name — because it is what this pipeline's model is held to. A caller's design may carry two more, both a chooser's decisions rather than a model's: the constraints block, and lcsc on a component, the catalogue code somebody chose, which resolve() orders as chosen instead of searching for again (resolve.declare() says why it is not in the schema). check_design let neither lcsc through the day the server shipped — a caller that searched the catalogue and pinned a code was refused for it — and does now; a code that is not C followed by digits is still refused by path.
A placement handed to build or rebuild is stored in the version's constraints.pinned with by: "import", so the workbench draws it as a decision and a later rebuild honours it; a whole placement also sets board.fixed, because it was made against that outline and a rebuild that grew the board would leave every part off-centre in it. A partial placement is pins, and the placer arranges the rest around them. Quarter turns only: the footprints, the DSN writer and the constraints block are checked at 0/90/180/270.
The knobs — router, seed, pour, precedent, min_track_mm — go where the rack's do, into constraints.rules, so the version records how it was built and a rebuild reproduces it. See design.json.
How a catalogue answer reads
search_parts and pins answer in lines, not JSON, unless the call says format: "json". The dict underneath is the same either way — the lines are what the text content says, and a caller that wants structuredContent asks for JSON. A search result:
search "" kind=resistor package=0603 value=10k "via=the resolver's ladder"
asked kind=resistor search=10k package=0603 value=10k
rows lcsc mfr package footprint kind stock price basic pads description category
* C98220 RC0603FR-0710KL 0603 0603 resistor 13677496 0.0035 - 2 "-55℃~+155℃ 100mW 10kΩ 75V ..." "resistors chip resistor surface mount"
- C99198 RC0603JR-0710KL 0603 0603 resistor 3993432 0.0037 - 2 "..." "..."
chosen C98220 drawn_as=0603
component kind=resistor search=RC0603FR-0710KL package=0603 value=10k lcsc=C98220
more 221 rows not shown; ask with a larger limit
note "..."A rows line names the columns once; each row after it is one line, * for the row a build would order for the component as asked and - for the rest; basic is B or -; price is USD; a value with a space in it is quoted. chosen names the mark with the footprint it is drawn as, component is the design line to copy, more says how much limit cut, and note and gap lines carry what the JSON put in those fields. A pins result is a pins line with the code, part number, package, symbol and kind, a pin pad name why header, one line a pin, then component and the notes. A refusal (ok: false) stays JSON and is marked as an error.
Why. On the 16 Sep 2026 replay of 60 boards driven through this server, a designer read about 86,500 tokens a board and 74% of it was these two tools' results — twenty catalogue rows as JSON is 3,036 tokens, most of them keys and quotes. The same rows as lines are 1,628 (1.86×) and a pins result 126 against 502 (3.99×), so a drive reads 35% less; and on 232 distinct results and 2,254 questions each, a model read the lines and the JSON equally well (both 2,254 of 2,254) and the lines a third faster. soldermask/tables.py writes them; tests/test_mcp.py decodes them back to every row, price, flag, note and gap; tools/mcp_ledger.py, tools/search_census.py and tools/catalogue_probe.py are the measurements. The former per-result note — the reader's legend — is in the tool description instead, once a session rather than once a call.
Resources
| URI | Contents |
|---|---|
soldermask://schema/design | DESIGN_SCHEMA: what a design must be. Every object is closed |
soldermask://schema/constraints | CONSTRAINTS_SCHEMA: pins, hand copper, the knobs |
soldermask://packages | the footprint names a design may use, and FROM_PART |
soldermask://docs/design-json, …/board-json, …/mcp | these pages, from a checkout (a wheel does not carry docs/) |
soldermask://projects/{slug}/{version}/{file} | any file a version holds — board.json, design.json, a Gerber, the .kicad_pcb, sim.json. Listed for every project's current version; the template is under resources/templates/list |
soldermask://projects/{slug}/{version}/fab.zip | not a file the version holds: the Gerbers, drill, BOM and CPL zipped as the app's download is (Project.export_zip), as a base64 blob — the fab package for a client that cannot reach this disk |
Progress
A build is 15–40 s. While a tool runs the server sends notifications/message (level info, one line a stage: resolving parts, placing, routing, saving) and, when the call carried a progressToken in _meta, notifications/progress with the same text — which is what keeps a client's timeout from firing on a board that is still routing.
Serving it to others
There is no thin MCP. Every tool above is a stage of the pipeline — search_parts needs the catalogue on disk, build needs the placer, both routers, kicad-cli and the judge — so a server somebody else can use is either the whole platform installed on their machine or the whole platform running on this one with a door in it. soldermask mcp --http is the door: the same Server, the same thirteen tools, the same judge, reached with a URL and a key. Nothing ships — not the code, not the routability model, not the catalogue, not the corpus. Since 17 Sep 2026; soldermask/mcp_http.py.
soldermask mcp keys add alice # prints sm_..., once; the file keeps its SHA-256
soldermask mcp --http --port 8787 # keys and projects under home(), one build at a timeThe client registers it the way it registers any remote server, with the key as a bearer header:
claude mcp add --transport http soldermask https://HOST/mcp --header "Authorization: Bearer sm_..."{ "mcpServers": { "soldermask": { "url": "https://HOST/mcp",
"headers": { "Authorization": "Bearer sm_..." } } } }The transport is the protocol's Streamable HTTP (2025-03-26, kept by 2025-06-18): one endpoint, POST /mcp, one JSON-RPC message a request (a batch on the older version). A request that is not a tool call is answered as JSON; a tool call as a text/event-stream carrying the progress and log notifications the stdio server sends, a keepalive comment every fifteen seconds while the router runs, and then the response — which is what keeps a client's timeout from firing on a board still routing. initialize mints an Mcp-Session-Id; a session the server has forgotten (a restart, a day's silence) is a 404 the client answers by initialising again. GET /mcp is 405; DELETE /mcp ends the session. The official Python SDK's client (mcp 2.2.0) was run against it on 17 Sep 2026: initialize negotiated 2025-06-18, thirteen tools listed, check_design 0.7 s, build 3.4 s to a passed five-part board under the A* router with eight progress notifications on the stream, fab.zip (12 files) read back as a resource.
A key is a directory. Every request under a key runs with its projects under <projects>/<name>/ (config.PROJECTS, set around the request and reset after), so build saves there, projects and resources/list see only that, and a resources/read of another key's board is "no such resource". The keys file holds digests, never keys, and is re-read on every lookup: keys add while the server runs works on the next request, keys revoke on the one after.
One build at a time. place, score_placement, build and rebuild each run a realise() — an anneal on every core, a router for seconds to a minute — and take a slot (--builds, default 1); a call that finds none says waiting for a build slot down its stream and waits up to ten minutes before returning a result that says the server was busy. search_parts, pins, check_design, verdict, drc, projects and status run at once.
What a remote client cannot have, said rather than skipped. A precedent's placement is a fragment of an open-hardware board whose licence the bank recorded and nobody has read as a whole (5,760 of 13,990 boards recorded none); it leaves this machine only under a permissive licence (MIT, BSD, Apache-2.0, CC0, CC-BY, CERN-OHL-P and the like), and otherwise start.withheld names the licence and matches still names the repository. A server path in a result means nothing over HTTP, so build, rebuild and verdict add resources: the URIs to read the version's files back, and fab.zip. status names the transport, the key and the key's own directory, and not the cache.
What to set before opening it. Bind to localhost and put TLS in front (a Cloudflare or Tailscale tunnel, a reverse proxy): a bearer key over plain HTTP across a network is a key given away. Origin is checked when a browser sends one (--origin allows one), which is the protocol's DNS-rebinding rule; agent clients send none. A build through the door labels its placements into the corpus like any other, so point SOLDERMASK_CORPUS at a directory of the server's own — other people's boards in the training corpus is a decision, not a default; the banner says where it is. Not built: OAuth, which Claude Desktop's connector list wants (Claude Code, Cursor and the SDKs take a header); a rate or a daily cap a key; a queue position for a call waiting on the slot.
What the score means
The routability model is a between-board judge: AUC 0.916 held out and 0.914 on PCBench, fitted 14 September 2026. Ordering one board's own placements is where it, the graph judge, the mould and the fold have each measured near chance (0.586 within a board). score_placement carries the model's numbers beside its rating for that reason, and on a poured board the rating is the analytic one and says so. Use it to catch overlap, off-board parts and rubric faults; use build to find out whether a placement routes. See Routability.
Under the hood
soldermask/mcp.py, and mcp_http.py for the door above. The transport is JSON-RPC 2.0, one message a line; the methods are initialize, ping, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list (empty) and logging/setLevel; protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are spoken, the client's when it is one of those. Nothing but protocol goes to stdout: the server takes the stream as the wire when it starts and points sys.stdout at stderr for as long as it runs. Every build is one workspace.realise() call and one commit(), the same two the command line makes — tests/test_mcp.py counts them, in process and over the pipes of a spawned server.