git:20261007.6f4922b to git:20261007.808fea0

7 added, 2 removed. Audit A to A.

# ltspice-mcp — Design
Scope, architecture, and the design principles you'll encounter when
using the server.
For install + client setup see [README.md](../README.md). For the parser
architecture see [docs/spice_lex.md](spice_lex.md). For current bugs and
limitations, check the GitHub issue tracker.
## Scope
The server targets **structured, validated, geometry-aware,
LTspice-specific** operations. Each adjective constrains what the
project invests in:
- **Structured** — tool outputs declare schemas; results are typed, not
free text.
- **Validated** — mutating tools refuse invalid states before touching
the file.
- **Geometry-aware** — `.asc` schematic operations work in symbol
coordinates, not just netlist tokens.
- **LTspice-specific** — `.asc`/`.asy`/`.raw` behavior and Windows
interop are handled directly. ngspice is fully supported for simulation,
result parsing, diagnostics, and analysis; it has no schematic format, so
the geometry tools do not apply to it. QSPICE and Xyce are best-effort
through spicelib's common interface.
### Platforms
The project is cross-platform and its main user base runs it natively on
Windows, where LTspice lives. WSL2 (the development host) and Linux are
supported, and the WSL interop — the `/mnt` drive mapping, `wslpath`, the
Windows-side temp directory for `.MEAS` — is a special case entered only
when the server detects WSL, never the general path. A design that assumes
POSIX (signals, process groups, symlinks, `\n`, UTF-8 defaults, binary
`os.open`) is incomplete until it states what it does on Windows.
### Why an MCP server vs. asking the LLM to use spicelib directly
An LLM with code execution (Claude Code, Cursor) could write spicelib
scripts directly. MCP applies in specific contexts:
- **Works without code execution.** Claude Desktop, ChatGPT, web chat
clients have no shell or Python. MCP tools are the only way to give
them simulation capabilities. This is the primary audience.
- **Reliability.** `run_experiments(circuits=[{"path": "foo.cir"}])` is a
tested code path. LLM-generated spicelib code makes import mistakes,
calls wrong method names, forgets `save_netlist()`, etc.
- **Context efficiency.** A tool call is ~50 tokens; equivalent Python is
20-40 lines. The difference adds up over an iterative design session.
- **Structured analysis as the default.** The `analyze_results` recipes
return schema-typed numbers: `bode_filter` for -3 dB points;
`bode_slope` / `bode_point` / `bode_crossing` for slopes, gains, and
crossings; `signal_stats` / `edges` / `transient_response`
(`mode="step"|"disturbance"`) for transient shape. The common questions
are answered without reading anything off an image. Decimated
raw-waveform export is the `waveform` recipe (a min/max stat-envelope for
seeing shape); rendered plots are the `plot` recipe and the
`plot_waveform` tool. Both complement the structured numbers for the
shape-recognition cases a scalar can't cover (see *Waveforms: scalars
first, export and plots for shape*); they do not replace them.
- **No user setup.** Install the server once. No spicelib in the user's
project venv, no Python knowledge required.
Where MCP is weaker: a fixed tool set is less flexible than arbitrary
Python, and there's an extra process to maintain.
## Comparison with alternatives
|tool|schematic editing|geometry validation|cross-run analysis|WSL|
|-|-|-|-|-|
|`ltspice-mcp` (this)|direct `.asc` editing|pin coords, bbox, diagonal-wire refusal, named-net-short detection|sweep + Monte Carlo + batch metrics|supported|
|spicelib `AscEditor`|text-level mutator|none|via SimStepper / Montecarlo (lower-level)|N/A|
|PyLTSpice|spicelib re-export|none|same as spicelib|N/A|
|`xuio/ltspice-mcp`|create/modify/lint|not compared|not compared|macOS-only|
|`daviditkin/ltspice-mcp`|netlist-level (9 tools)|none|none|not compared|
|SPICEAssistant (arxiv 2507.10639)|none|N/A|measurement extractors|N/A — research only|
|LTspice's own MCP server (26.1+)|replaces a whole open sheet with text the model wrote|none|none: one run of the open design, raw waveform reads|Windows only|
|LTspice GUI|interactive|interactive|GUI-driven|N/A|
"not compared" means we have not run that project and are not claiming
anything about it either way. It is not a statement that the capability is
absent — the other cells are read from each project's own description, and
none of the third-party rows have been benchmarked against this one.
The row for LTspice's own server is read from the tools the 26.1.1 bridge
lists, not from a description of it.
### LTspice's own MCP server
From 26.1 LTspice carries an MCP server: each LTspice window listens on the
loopback interface, and `ltspice-mcp-bridge.exe`, installed beside
`LTspice.exe`, is the stdio program a client registers. It does a different
job from this server. It is a remote control for the window: it lists and reads
the documents that are open, replaces one with text the model wrote, asks for
the netlist, starts a run of the open design and reads a log or samples from a
raw file. It has no notion of a pin, a sweep, a job or a metric, and its own
help advises keeping a model away from drawing.
Most of that this server already does on files, with more behind it: typed and
validated ops in place of whole-sheet text, experiments in place of one run,
recipes in place of raw samples. None of it is routed through the bridge. Its
reference documents are files in the install, and `inspect(kind="simulator_docs")`
reads them from there.
The one thing only LTspice can reach is the window, and that is what the bridge
is used for. LTspice reads a sheet once: a sheet edited on disk while it is
open stays as it was on screen, and the window's next save writes the old sheet
back over the edit. So `edit_schematic` asks the bridge whether a window has
the target open, refuses to commit under one whose copy differs from the file,
and after a commit replaces the window's copy with the committed sheet, which
appears at once and is one step of the window's undo history
(`lib/ltspice_window.py`; the contract is in
[mcp_surface.md](design/mcp_surface.md), "A sheet open in LTspice"). The same
route answers "what do I have open" (`inspect(kind="open_in_ltspice")`), and
- opens a finished run's results in the window when a person asks to see it
- there (`plot_waveform(in_ltspice=true)`, under *Export & plot surface* below).
+ puts things in the window when a person asks to see them there: a sheet
+ (`verify_circuit(in_ltspice=true)`) or a finished run's results
+ (`plot_waveform(in_ltspice=true)`, under *Export & plot surface* below). Each
+ is an argument on the tool that already shows that thing, a sheet or a run,
+ and not a tool of its own: there are two such actions, each has an object the
+ surface already names, and a tool for the window alone would be the remote
+ control LTspice's own server is.
The file stays the record, and the window is a view of it. LTspice's server
takes the other side: the window is the document and saving is the user's. Had
this server followed, every tool that reads a file (a run, an export, a
comparison) would read something behind what the user sees.
Two constraints came from observing the bridge and are recorded in
`tests/fixtures/ltspice_bridge_recorded`. The first is that it starts an
LTspice of its own when it has none to talk to, on any request that needs one
and even when told which instance to use. Nothing in the bridge turns that off,
so it is contained instead: the bridge is started on the server's hidden
desktop, in a job that ends with the session, and whatever it launches has its
windows there and does not outlive the session
(`lib/hidden_desktop.py`, which is where anything that can start LTspice on
Windows is started from). It is also run with `--ltspice-path` naming a file
that does not exist, which makes the launch fail before it starts; that keeps
the common case from starting anything at all, but it is a failure the bridge
reports, not a mode it offers, so it is the second line and not the first. The
session lists the windows and attaches to one by process id before it asks for
anything, so a launch is attempted only when that window closes in between.
The second is that what a window hands back is LTspice's own writing of the
sheet, which differs from the file in ways that change nothing, so the
comparison reads content and not text. Only the bridge's stdio interface is
used; the loopback protocol behind it is not documented and is left alone.
Geometry-aware editing is `edit_schematic`, one transactional op batch
(`add_component`, `move_component`, `remove_component`,
`set_component_value`, `set_component_attribute`, `wire_pins`,
`add_net_label` with `pin="M3.S"`, `remove_net_label`, `remove_wire`,
`add_directive`, `remove_directive`). The ops work against pin
coordinates, bounding boxes, and named-net topology: the `wire_pins` op
refuses diagonal wires,
pin collisions, wire-junction overlaps, named-net shorts, and a waypoint
that would join another net's wiring before touching the file, takes a
point on an existing wire as an endpoint to draw a T-junction, and the
batch returns geometry the agent can use in its next call. `inspect(kind="symbol")` is the read-only half
of that workflow: it returns pin positions and bounding boxes so the caller
can plan an edit. It does not validate one.
### One authoring tool, many ops
Schematic mutation is a single tool with a discriminated op union rather
than one tool per mutation. A tool's schema costs the model context whether
or not the tool is ever called, and MCP guidance is to prefer fewer, more
capable tools (tool-selection accuracy degrades past roughly 15 tools). So
the ops that return geometry (`add_component` gives placed pins, bounding
box, and overlap warnings; `wire_pins` gives the routing result) and the
ops that only acknowledge (`move_component`, `remove_component`,
`set_component_attribute`, `add_net_label`, `remove_net_label`,
`remove_wire`) all go through the same call, and a caller batches them into
one transaction. Creating a sheet is the same tool with the blank base.
Reading (`inspect`) is a separate tool, because a read-only operation
should not be hidden behind a mutating tool's schema.
## Design principles
### Validate before write
Mutating tools validate before writing and refuse states they can
prove invalid.
The `wire_pins` op is the reference example: a refusal names the specific segments,
pins, or labels that blocked the write so
the agent can pick a new waypoint instead of guessing.
Agents are bad at undoing mistakes. Tools that refuse invalid states save
entire conversation turns of recovery.
### No auto-routing
When the server refuses a wire, it returns the **conflict set**: what
blocked it and where. It does not calculate an alternate route. General
routing is NP-hard and is outside the project's scope; the caller uses the
conflict set to choose another route.
### No preview mode
There is no `dry_run` / preview parameter. Three mechanisms cover safe
mutation instead:
- **Validate-before-write refusals** — the `wire_pins` op refuses invalid
geometry before the file is touched, with itemized error text naming
the conflicting segments, pins, or labels; the `add_component` op
places the part and returns its pin positions plus
non-blocking overlap warnings.
- **All-or-nothing commits.** An `edit_schematic` batch is applied to one
in-memory editor and written by atomic rename. `expected_sha256` names
the revision the batch was prepared against; if another writer committed
first, the call returns `revision_conflict` and writes nothing. A failed
batch leaves the sheet unchanged, so recovery is re-reading the file, not
undoing a partial write.
- **`verify_circuit` comparison** — a committed sheet is exported on a
copy and compared against a reference netlist or schematic (equivalence or
structural diff; an `.asc` reference is exported the same way), so
unintended drift is visible immediately.
### Post-op validation pass
Even when every op succeeds, the response includes schematic-level
warnings (floating pins, duplicate wire segments, dangling net labels).
They are cheap to compute in the same editor session and save the agent a
follow-up inspection call.
### Structured outputs
Tools that produce inspectable state declare an `output_schema` and
return `structuredContent` alongside text. When MCP clients run code
execution against tool calls, the schemas serve as the types the
sandboxed code consumes. The schema stays on the registered definition and
is not advertised in the tool listing: it was the largest block there, and a
caller learns a response shape from a response.
### The tool listing is what the source says
A client loads every tool definition before it can call anything, and pays
for it in every turn of the conversation afterwards. That cost is managed by
writing each argument description short — the unit, the sign or direction
convention, the default, how the field interacts with its siblings — and
putting the depth in `docs/design/mcp_surface.md` or a section of the packaged
guide, with a pointer on the field naming the section. It is not managed by filtering
the descriptions on the way out: what a model declares is what a client is
shown, so a reader of the source knows what ships, and the one text also
serves `api.reference()` and the guide.
### Waveforms: scalars first, export and plots for shape
By default the analysis tools return scalars, not samples: time-weighted
RMS, rise time, phase margin. The consumer is an LLM, and a correct scalar
is easier to reason over than a large array of points. This default covers
waveforms whose shape a single number captures; it answers "what is X". It
does not cover cases where the shape itself is the result: switching-
converter nodes, amplifier internal nodes, startup transients.
Those cases fall into two groups with different needs:
- **Known shape question** (subharmonic oscillation, CCM/DCM mode, slew
limiting, switch-node ringing) → a specialized detector (FFT, mode
detector, dV/dt), not raw data: it returns the number directly instead
of making the caller read it off a curve. Prefer a detector when one
exists.
- **Exploratory "let me look"** (why won't this converter start?) → no
detector helps, because the metric is not known yet; you look, form a
hypothesis, then measure. This is the case waveform export exists for.
What matters is who consumes the output and in what format, not scalar
versus waveform:
- **LLM computing** → decimated numeric arrays, with **min/max envelope**
decimation (each bucket carries its min and max), never stride — stride
drops glitches and ringing peaks; the envelope preserves every
extremum, losing only sub-bucket timing. The LLM computes on the array
(FFT, period, overshoot), which is more reliable than reading a picture.
- **LLM recognizing shape** → a rendered plot, as a backup and complement
to the array. A vision model sees overall shape (slewing, a settling
envelope, "this looks unstable") at a glance, which is tedious to derive
from numbers; plot plus array is more robust than either alone.
- **Human** → the same output handed off (CSV to a viewer, PNG inline).
Recognition reliability is not a concern here.
So the surface is layered: scalar detectors for "what is X", specialized
shape-detectors for known signatures, decimated export for "let me look",
and plots as a shape-recognition backup for both LLM and human. Decimated
export is the `waveform` recipe; rendered plots are the `plot` recipe and
the `plot_waveform` tool.
**Scalar-guided zoom.** The waveforms this server handles (switching
nodes, amplifier internal nodes) are multi-scale: a piecewise-smooth
backbone plus sparse localized events (ringing spikes, coupling glitches,
crossover kinks). No single fixed-resolution view fits, so the caller
narrows in step by step instead of relying on one lossy encoding. The
overview is a **stat-envelope**: per bucket, `[min, max, rms, mean]`.
Min/max guarantee that a narrow spike's amplitude survives bucketing;
rms/mean localize energy and drift. From the per-bucket values the consumer
picks a sub-window and re-requests it at finer resolution (the same
`waveform` recipe with a narrower `window`), and repeats. Full-resolution
data stays on disk; detail enters context only when a measured value
justifies it. This is the same look-then-zoom loop an engineer uses on a
scope, and it reuses windowed export plus the detectors (an
envelope+carrier or edge-metric descriptor is the payload for a flagged
window at zoom time).
**Report facts without rating them — applied to navigation.** The stat-envelope and
any ranking follow the same rule as the rest of the result layer: the tool
reports measured conditions (crest factor, a bucket's spread relative to
its neighbors, `mean` drift, alternating-peak spacing, spectral
concentration) and may sort buckets by a named quantity, but it attaches
**no phenomenon label and no trust verdict**. "Crest factor 8.1 here, 5.2×
the median bucket" is a fact; "spike", "unstable", or "unreliable" is the
model's conclusion. Relative comparisons are preferred to bare-magnitude
thresholds. The model decides what the shape is and where to look next;
the server reports the facts.
**Optional always-attach plot.** A config default plus a per-call tool
parameter make a response include a rendered plot (MCP `ImageContent`) of
the queried window in addition to the scalars, not instead of them. The
plot is the shape-recognition layer for the vision case above. Because it
renders the queried window, the same zoom loop re-renders it at finer
scale. Off by default (token cost, non-vision clients); on for clients that
want a plot with every result; a per-call override is available either way.
Built on `plot_waveform` (`attach_plot`, below); the `analyze_results`
recipes do not attach one.
### Export & plot surface (as of 2026-06-13)
The layered surface above maps to three delivery channels, one per
consumer. The channel for each is fixed even though one library choice is
still open.
| Consumer | Needs | Channel |
|-|-|-|
| The agent (computes) | the numbers, full fidelity | the `waveform` recipe in `format: "csv"` → CSV on disk |
| A vision model (one frame) | shape-at-a-glance | static PNG (`ImageContent`) attached to a `plot_waveform` result (`attach_plot`) |
| A human (explores) | an interactive plot | `plot_waveform`, rendered to the richest surface the client supports |
One constraint affects the last two: the terminal CLIs do not render images
for the human. Claude Code passes an `ImageContent` PNG to the model (vision
works) but never shows it to the user; Codex handles tool-returned images
unreliably (it tends to dump base64 into context). The MCP "apps" widget
surface (`ui://` HTML in a sandboxed iframe) is available only on GUI hosts
(Claude Desktop / claude.ai web), not in the terminal. And interactivity
benefits the human, not the LLM: a model consumes a single rendered frame,
so zoom / pan / hover does nothing for it.
- **The `waveform` recipe in `format: "csv"` — full-fidelity export to disk.**
Returns a path, not data; full resolution in a response would exceed the
context budget, which is why the inline form decimates. Works on every
analysis type. `.step` / Monte-Carlo runs are written in long (tidy) form
(`step_index, step_value, x, <signals…>`) because transient `.step` runs
have a different time vector per step, so a wide shared-`x` layout would be
wrong. Complex AC traces are written as magnitude (dB) + phase (deg)
columns: lossless, plot-ready, and the form the AC structural-analysis
functions read. This is the only path that emits every sample of the
complex `H(f)` array (the inline form decimates and the Bode recipes return
scalars), so the AC structural-analysis layer is built on it. It exports
raw data only: no derived slope / group-delay / residual columns (those
belong to the detectors). Size is bounded by windowing (the zoom loop
above), not by decimation. No new dependencies. Column scheme: a
unit-tagged x header (`time_s` / `freq_Hz` / `sweep`); real traces under
their canonical name (`V(out)`); complex traces as `V(out)_mag_dB` +
`V(out)_phase_deg` (or `_re`/`_im`, or all four). Phase is the wrapped
`np.angle`; a consumer runs `np.unwrap` itself. Non-finite samples are kept
and counted, not dropped, so columns stay row-aligned. The CSV is written
under `<working_dir>/.ltspice-mcp/results/artifacts/<result_set_id>/`
(Linux-side, never beside a Windows-temp raw). A descending or
non-monotonic axis is refused when windowed (searchsorted would corrupt
it). Rows stream straight into the atomic temp file (no whole-CSV copy held
in memory) under a generous row-count limit that raises rather than
truncates; and in a stepped run, a
step whose axis misses the window is skipped and reported rather than
failing the whole export.
- **`plot_waveform` — one chart core, two delivery paths.** One interactive
chart, delivered one of two ways depending on the capabilities the calling
client declared: an in-chat `ui://` widget for an apps-capable GUI host (in
practice Claude Desktop for a local stdio server), or a self-contained HTML
file opened on the local desktop for a terminal client (a chromeless
Chromium/Edge `--app` window when one is on the path; otherwise, on WSL,
`explorer.exe` on a `wslpath -w` conversion, and `xdg-open` / `open` /
`os.startfile` elsewhere). Either way the tool also returns the file path
and a summary of each plotted trace, read from every sample in the window
before any decimation: min and max and the axis value where each occurs,
the first and final value, and on a transient the time-weighted mean (a
mean over a swept voltage or a log frequency axis describes the sweep,
not the circuit, so it is left out). An AC trace is summarized as
magnitude in dB plus its unwrapped phase at both ends. A stepped or Monte
Carlo overlay summarizes each step, up to a cap the reply reports. The
model gets the numbers the chart shows without seeing it, and continues
from the path.
Panels follow units: traces of one declared unit (`trace_unit`, the
simulator's own type, never a guess from the name) share a panel, so
volts and amps never share a y-axis, and an AC plot gets a magnitude and
phase pair per unit. A noise run's densities are V/√Hz, and input-referred
noise takes its unit from the deck's `.NOISE` source when there is one.
Scale is never guessed at: a caller who wants a 10 mV signal off a 4 V
swing's axis names the layout with `panels`. Every multi-panel chart
shares one x cursor, and the static image draws every panel over the same
x range. Because the
server is local, an interactive plot needs no widget infrastructure: it
opens a window or browser that the OS renders, which works the same under
any CLI. The chart is built on uPlot (~50 KB, zero-dependency, canvas-2D)
inlined into one self-contained HTML file. It inlines cleanly into both
the offline file and the payload-capped widget and can render
full-fidelity transients; Plotly (~1.5 MB, heavy to inline per widget) lost
on both inline size and rendering performance with full data. It is not a
Python plotting dependency. Fidelity: full by default, a `max_points`
parameter to override, and a min/max-preserving downsample that engages
only above a high cap and is reported in `observations` (no silent
truncation). Both delivery paths are implemented. uPlot is vendored as a
bundled MIT asset under `src/ltspice_mcp/assets/uplot/` (in the wheel,
inlined at render; no pip dependency, no CDN). A step whose axis misses the
window is skipped; a panel's data is one or more tables `[x, y...]`, so
traces on one axis share a table and a transient `.step` overlay with
differing per-step time vectors has one per step, which the chart aligns
with `uPlot.join` (it joins a line across another table's samples and
breaks it at the trace's own `null`) and the static image draws against
each table's own x; AC Bode phase is unwrapped; non-finite samples become
JSON `null` gaps. The traces are read and windowed once per call; the file,
the widget spec and the image are projections of that at their own point
budgets. A global per-panel cell cap refuses, before building it, a plot
whose aligned size would be too large. Delivery is chosen by the capabilities the calling client declared
(`capabilities.extensions["io.modelcontextprotocol/ui"]`) — in the
`initialize` handshake, or in the per-request envelope on a 2026-07-28
connection, which has no handshake; `server.py:get_client_capabilities`
reads whichever the connection carries:
- **MCP Apps host (SEP-1865, Final 2026-01-26)** → an in-chat `ui://`
widget, wired the standard way rather than by inline embedding: the
`plot_waveform` tool declares `_meta.ui.resourceUri`; one stable,
predeclared renderer resource (`ui://ltspice-mcp/plot`, uPlot plus the
vendored Apache-2.0 ext-apps `App` runtime inlined) is served via
`resources/read`; the host renders it in a sandboxed iframe and passes it
a compact chart spec (decimated harder than the file, carried in the
result `_meta`, a channel the model does not see, so the chart data
never reaches the model, which reads the trace summaries instead),
which `app.ontoolresult` draws. The full-fidelity HTML still lands on
disk; an oversized spec (byte cap) or a
build failure falls back to local-open and is reported.
- **Terminal client** → the self-contained HTML is opened locally
(`explorer.exe` via `wslpath -w`, else `xdg-open`/`open`/`startfile`),
spawned detached so it never blocks.
Both always return the file path and a text summary, so a host with neither
surface still gets a usable result (the fallback the MCP Apps spec
describes).
- **In the user's LTspice — on request, on `plot_waveform`.** A person who
works in LTspice wants to look in LTspice: its cursors, its Add Trace box,
the viewer they already know. `in_ltspice` opens the run's results file in
the LTspice window that is already running, through the bridge LTspice
ships (`OpenWindows.show_results`). A results file opened there shows an
empty plot unless a plot settings file of the same name sits beside it, so
the server writes one naming the panels' traces (`lib/plot_settings.py`):
the traces only, which is enough for LTspice to draw them and scale the
axes itself. A settings file a person saved from LTspice is left alone, and
is told from one written here by what it holds. LTspice is not started for
this; with no window open the settings are still written, so the file opens
with its traces when the person opens it by hand. It is the one place the
server opens anything in a window, and it does so only when asked, which is
why it is an argument and not a setting. The chart and the trace summaries
are made all the same, and the browser is not opened as well. What LTspice
loads when it opens a results file it does not load again while the file
stays open, and nothing the bridge answers says whether it was open, so the
reply says that a file already open keeps the traces it had.
- **Static PNG (the vision tier) — opt-in, on `plot_waveform`.** A config
default `[analysis] attach_plot` (off) plus a per-call `attach_plot` tool
parameter that overrides it: an operator can attach a plot to every
`plot_waveform` result, and the model can opt in or out for a single
call. Default off (a base64 PNG on every result is expensive, useless to
non-vision clients, and barely works in Codex). The image is drawn from
the same plot spec as the interactive chart, so the two cannot disagree
on panels or labels, by a plain-Python SVG renderer
(`lib/plot_svg.py`) and rasterized by the optional `raster` extra
(cairosvg) that schematic renders already use, instead of the
matplotlib `[plot]` extra first planned: no plotting dependency, and one
optional native library instead of two. Without the extra or the native
Cairo library the image is skipped and reported as an observation naming
the missing piece and its remedy (`raster_support`, the same record
`inspect(kind="capabilities")` reports); SVG markup is never sent in its
place, because path data is no use to a model and costs more than the
picture. The PNG is also written beside the HTML. It is about a thousand
tokens for two panels (the reply reports `estimated_tokens`). Its points
are budgeted in total rather than per series, since rasterization time
follows the points drawn and the plot area is under 700 px wide: a
many-step overlay gets fewer points per step, never a slower render. The
`analyze_results` recipes do not attach an image.
**Fidelity by consumer.** The inline `waveform` recipe decimates (the LLM's
context is the limit), its `format: "csv"` form is lossless (disk has no such
limit), and
`plot_waveform` defaults to full fidelity (a browser is not context-bound),
capping only at very large sizes.
**Dependencies.** The core install adds no plotting dependency. The
interactive channel adds only uPlot (~50 KB, zero-dep, inlined); the
static-PNG tier is plain Python up to the SVG and needs only the optional
`raster` extra to rasterize it.
**Renderer principle:** the plot layer takes plain arrays plus labels and
knows nothing about job or run internals, so the three channels stay
swappable behind one data contract.
## Architecture
```
MCP protocol layer server.py — lifespan, dispatch, request routing
resources.py — MCP resources & URI templates
Tool layer tools/*.py — tool definitions + handlers
Core logic lib/*.py
Config / state config.py, state.py, errors.py
```
Tool modules use a decorator-based registry (`@registry.tool(...)`).
Each registration declares `name`, `description`, `input_model` (Pydantic),
`annotations`, and an optional `output_schema`. `SessionState.create()`
builds the surface once during lifespan init; the dispatch table and the
tool definitions both come from `registry`.
Key `lib/` modules:
|module|purpose|
|-|-|
|`services.py`|service layer shared by tools and resources — job resolution, cached result loading, reusable extraction|
|`runner_base.py`, `experiment_runner.py`|the spicelib runner wrapper and the experiment coordinator built on it|
|`schematic_ops.py`|the `.asc` edit engine — op models, appliers, geometry, net tracing|
|`runner_manager.py`|centralized runner lifecycle with auto-invalidation on loop / simulator / output-folder change|
|`simulator.py`|simulator detection, WSL/Wine selection|
|`simulator_build.py`|which build ran: the executable a job launches, and the version each run reports in its own output|
|`ltspice_wsl.py`, `wsl.py`|WSL path conversion and Windows interop|
|`raw_parser.py`, `log_parser.py`|simulation result parsing|
|`library_manager.py`, `library_parser.py`|component library handling|
|`symbol_geometry.py`|`.asy` symbol parsing, pin positions, rotation transforms, bounding boxes|
|`spice_lex.py`, `spice_lex_ops.py`, `spice_lex_views.py`|shared SPICE lexer pipeline — see [docs/spice_lex.md](spice_lex.md)|
|`pathutil.py`|path security (`safe_path()`, `resolve_safe_path()`)|
### The tool surface
There is one tool surface and nothing selects it: the guide has one edition,
the prompts have one edition, and no config key names a profile.
`inspect(kind="capabilities")` reports `tool_profile: "consolidated"` so a
client can read which surface it is talking to. What `[tools]` does select is
`listing` — `compact` (the default) or `full`, the two amounts of each
definition the same tools can be advertised with (see
[docs/design/mcp_surface.md](design/mcp_surface.md), "Two listing modes").
|surface|tool count|use case|
|-|-|-|
|`consolidated` (the only one)|8 tools|Any MCP client: `run_experiments`/`jobs` (execute), `analyze_results`/`inspect` (understand), `edit_schematic`/`verify_circuit` (author), plus the `plot_waveform` widget, plus `run_code` (a Python snippet in a warm worker holding the engine; served only when `[tools] run_code = true`)|
Each of the six tools takes a declarative payload rather than a fixed
argument list, so the number of capabilities did not shrink with the number
of tools: sweeps, corners and Monte Carlo are `run_experiments`
`variations`; every former analysis tool is an `analyze_results` recipe;
every former schematic mutation is an `edit_schematic` op; the former read
tools are `inspect` kinds. Netlist text editing has no tool at all: an
agent with file access does it natively, and a wrapper added nothing.
**What 0.6.0 removed.** The `full` (49-tool) and `agentic` (41-tool)
profiles, and with them one tool per operation, the single-simulation and
batch job types those tools ran, and the runners behind them. The job
sidecars those releases wrote beside a circuit are not read any more: a job
id only that file knows is simply not found. `[tools]
profile` and `LTSPICE_MCP_TOOL_PROFILE` are no longer read: a config that
still sets one loads with the key ignored, like any other key the server
does not read. A deployment that needs the old tools pins
`ltspice-mcp==0.5.*`. The handlers behind those tools were kept as internal
adapters through 0.6 development, so the consolidated surface could be shown
to run the same code paths; they are gone now, and the numeric core they
carried lives in `lib/metrics.py` as one function per recipe.
## Backend: spicelib
`spicelib` (>= 1.4.9, < 1.6) is the core Python library for SPICE
automation, by Nuno Brum. PyLTSpice is a thin re-export wrapper over
spicelib that adds nothing — we depend on `spicelib` directly. The
ceiling is deliberate: spicelib 1.6 retypes `.PARAM` values (`float_unit`)
and refactored the component model, which breaks the parameter and
structural-comparison paths. See the pin comment in `pyproject.toml`. Raise
the bound only when something forces it, not to pick up features.
All four simulators share the same base `Simulator` ABC:
|simulator|class|platform|
|-|-|-|
|LTspice|`LTspice`|Windows native; Linux/macOS via Wine; WSL via Windows interop|
|NGspice|`NGspiceSimulator`|Linux/macOS/Windows native|
|QSPICE|`Qspice`|Windows native only (runs refused under WSL or Wine)|
|Xyce|`XyceSimulator`|Linux/macOS/Windows native|
Core spicelib components used:
|component|purpose|
|-|-|
|`SpiceEditor`|read/modify/write `.net`/`.cir` netlists|
|`AscEditor`|read/modify `.asc` schematics (LTspice)|
|`SimRunner`|batch execution with parallel-sim support|
|`RawRead`|parse binary `.raw`/`.qraw` waveform output (dialect auto-detection)|
|`LTSpiceLogReader`|extract `.MEAS`, step data, Fourier|
|`SimStepper`|multi-dimensional parameter sweeps (overcomes the 3-parameter `.STEP` limit)|
Monte Carlo is not spicelib's `Montecarlo` toolkit: perturbation lives
in the in-repo pure engine (`lib/montecarlo.py`) and runs through the
same `SimRunner`-based execution path as everything else.
Per-simulator notes:
- **LTspice** supports `.asc` → `.net` conversion via spicelib's
`LTspice.create_netlist()`.
macOS LTspice has no CLI switch support. Default switches: `-Run -b`.
- **NGspice** has a compatibility mode (`kiltpsa` default for
KiCad/LTspice/PSPICE), overridable via `[simulator] ngbehavior` (or the
`LTSPICE_MCP_NGBEHAVIOR` env var) — e.g. `hsa` when a sectioned
`.lib <file> <section>` corner select must survive, since `kiltpsa`'s
`lt`/`ps` tokens make ngspice read it as two plain includes and drop the
section. Default switches: `-b -o -r -a`. Native on Linux.
- **QSPICE** uses `.qraw` (double precision, a double frequency axis on AC).
A run can select it only when the server runs natively on Windows:
spicelib starts it with the host's own file paths, and only LTspice has a
path adapter for a Windows simulator under WSL (`LTspiceWSL`) or Wine, so
elsewhere `execution.simulator="qspice"` is refused with that reason
(`simulator.family_refusal`) and capabilities reports it `selectable: false`.
- **Xyce** supports `-syntax` and `-norun` for validation without
simulation. Its raw carries no `Command:` field, so the dialect a job's
results parse with comes from the simulator the job recorded.
- **Schematics on QSPICE and Xyce.** A `.asc` runs through LTspice's export,
which is in LTspice's own netlist dialect and is scrubbed only for ngspice,
so a schematic circuit on either fails with `asc_export_unavailable` before
LTspice is launched (`simulator.asc_export_refusal`); a run takes a
hand-written netlist there.
### Which build ran
A result is only as reproducible as the simulator build that produced it, so
the build is a recorded fact rather than an assumption
(`lib/simulator_build.py`):
- **The executable, per job.** At submission the job records the program its
cases will launch: path, size, modification time and SHA-256
(`simulator_executable`). The program is the last element of spicelib's
`spice_exe`, so under Wine it is `LTspice.exe` and not `wine`. The digest is
computed once per build in a process.
- **The reported build, per case.** When a run ends, the build it named in its
own output is read into the case (`simulator_version`). LTspice 24 and later
put it on the log's first line (`LTspice 26.0.2 for Windows`). ngspice's `-o`
log carries no version, but its console banner does, which the runner
captures in the run's `.exe.log`
(`ngspice-42, Creation Date: Sun Mar 31 20:15:14 UTC 2024`). Xyce opens the
log it is told to write with a banner naming its release
(`Xyce Release 7.8.0-opensource`). The raw header's `Command:` is the
fallback: LTspice XVII writes no log banner and names itself only there, and
QSPICE names itself there too. Every read is a fixed number of bytes from
the head of an artifact. Simulator output is untrusted input.
- **Replay compares them.** A reused `request_id` whose job ran on a different
build than the one the request would launch now is an `idempotency_conflict`,
as an edited deck is. The executable is checked beside the fingerprint rather
than hashed into it. It is server state, not request content, and folding it
in would report a swapped executable as "a different request payload".
- **Capabilities reads runs, not the executable.** `inspect(kind="capabilities")`
reports each simulator's executable and digest, and as `version` the build
that the latest run on that same executable reported. Asking the executable
would mean launching the simulator just to fill in a report.
One executable per simulator family per process remains the rule: spicelib's
`create_from` sets `spice_exe` on the shared class. Running a second build
means a second process on the same working directory, e.g.
`Api(working_dir=..., simulator_exe=".../XVIIx64.exe")`. Both processes read
and write the same job records, and each job records which build ran it, so
the two builds' results stay distinguishable. A per-run executable choice
(named executables in config, each a simulator subclass with its own
`spice_exe`, runner and permits) would change the `execution.simulator` input
contract and simulator detection. It is left until a task shows the
two-process route falls short.
## WSL support
On WSL, LTspice runs via Windows interop, not Wine. Operational
adaptations:
- **Path conversion**: `LTspiceWSL` subclass (`lib/ltspice_wsl.py`)
overrides `run()` to convert paths via `wslpath` instead of Wine's
`Z:` prefix. Auto-selected when `is_wsl()` is True.
- **`%LOCALAPPDATA%` resolution**: symbol-library paths under
`%LOCALAPPDATA%` are resolved via `cmd.exe` since they vary by
Windows user profile.
- **Windows-side output dir**: simulation output goes to a Windows-native
temp dir when the working dir is on the Linux filesystem. Required for
`.MEAS` results — LTspice's SQLite `.db` writes fail on UNC paths
(`\\wsl.localhost\...`), which loses measurement data from `.log`
files.
- **Extension preservation**: LTspice requires netlist files to have an
extension (`.cir`, `.net`, `.sp`). `runner_base.submit_netlist` preserves
the original extension in `run_filename`.
`simulator.path` in `ltspice-mcp.toml` must point at the
Windows-side executable (e.g.
`/mnt/c/Program Files/ADI/LTspice/LTspice.exe`) — spicelib can't
auto-detect across the WSL boundary.
## Diagnostics
`log_parser.py:extract_log_diagnostics()` extracts structured warnings
and errors from simulator log files — parse errors with caret pointers,
fatal errors, convergence messages, `.MEAS` parse failures — and
`run_experiments`, `jobs`, and the `summary` recipe attach them to
their responses as `warnings` / `errors` lists. An agent driving raw
`ngspice -b` gets a 200-line log dump and has to grep; here the same
failure arrives inside the tool response.
Above the raw lists, the observation surfacer
(`lib/result_observations.py`) adds an `observations` list to the run
summary with the facts most likely to matter:
|observation kind|what it surfaces|
|-|-|
|`relay`|the simulator's own error lines, with the simulator's severity (never an invented one)|
|`reconciliation`|requested `.meas`/`.four` outputs that were not produced|
|`value`|non-finite samples and extreme node values in the trace data|
|`coverage`|checks that were skipped, so a thin result can't pass as a clean one|
The surfacer does not rate the result; it reports facts for the model to
judge. An empty `observations` list means nothing tripped a check, not that
the result is verified correct.
**Scope:** LTspice and ngspice both get the full pipeline; ngspice's stdout
diagnostics are captured alongside its log file and fed through it. qspice
/ xyce get best-effort convergence and fatal-error parsing.
## Non-goals
These are intentional gaps, not pending features.
- **Auto-routing / auto-placement.** Conflict-set returns on refusal
are the substitute. General routing is out of scope.
- **Semantic part search** ("find me a low-Vgs-th NMOS under 100mΩ
Rds(on)"). Requires parsing every `.model` card, normalizing units
across vendors, building a queryable parameter index — a separate
parameter-database effort.
- **Thin wrappers around file-edit operations.** Netlist text editing has
no tool: a client with native `Read`/`Edit` does it better, and one
without it can still hand the server a deck it wrote.
- **Per-simulator parity for geometry.** LTspice and ngspice are
co-equal for simulation, result parsing, diagnostics, and analysis.
The geometry layer (`.asc`, `.asy`, symbol coordinates) stays
LTspice-only by nature — ngspice has no schematic format to edit.
QSPICE and Xyce get best-effort support through spicelib's common
interface.
- **Auth / multi-tenancy / remote-MCP plumbing.** This is a local tool:
no OAuth flows, no per-tenant isolation.
- **Generic cross-simulator EDA features** that PyLTSpice and ngspice's
CLI already cover well.
## Roadmap
Forward-looking and **not commitments**. Priorities follow the scope
statement at the top of this doc: work that is structured, validated,
geometry-aware, and LTspice-specific comes first.
- **Layout primitives**: `route_bus`, `align`, `distribute` for the
rows of caps/resistors agents constantly need to place.
- **`ltspice-mcp doctor`**: one-shot diagnostic that checks simulator
detection, symbol-path resolution, output-dir placement, Wine vs WSL
selection, `.MEAS`-on-UNC risk. The `flutter doctor` / `brew doctor`
pattern.
- **Diagnostic taxonomy expansion**: singular matrix, gmin stepping
suggestions, timestep too small, missing model cards, unmatched
subcircuit pins, `.STEP` parameter mismatches. Each gets a
`suggested_fix` field. LTspice- and ngspice-scoped.
- **Cross-run analysis**: `compare_corners`, `find_worst_case`,
`sensitivity_ranking` — tools that aggregate measurements across a
set of runs and return structured deltas.
- **Waveform export & plotting**: shipped (see *Export & plot surface*
above) — the `waveform` recipe's CSV form, both `plot_waveform`
delivery tiers, and its opt-in static PNG for the vision tier. What
remains open is attaching that image to `analyze_results` recipes.
- **Pin-compatible alternate suggestions** for unknown parts.
## Configuration
`ltspice-mcp.toml` in the working directory, written with defaults on the
first tool call if missing (`LTSPICE_MCP_WRITE_CONFIG=false` turns that off).
Environment variables with `LTSPICE_MCP_` prefix override TOML values.
See `config.py:ServerConfig` for all options.
What the server writes, and where: the working directory's `.ltspice-mcp/`
store (records, runs, result sets, export snapshots, plots), a per-user home
(`%LOCALAPPDATA%\ltspice-mcp` on Windows, the XDG state directory elsewhere,
`LTSPICE_MCP_HOME` to override) for the recent-circuits index and the
per-circuit lock files, and nothing beside a circuit that a caller did not
ask for. `LTSPICE_MCP_STORE_DIR` moves the store to one directory per working
directory under the named directory. The working directory stays what scopes
the records (idempotency, job listings, result sets), but a process now finds
them only if it carries the same setting: a server or script started without
it looks in `<working_dir>/.ltspice-mcp/` and sees none of them.
`lib/store.py`'s module docstring is the layout.
TOML sections: `[simulator]`, `[security]`, `[simulation]`, `[analysis]`,
`[logging]`, `[schematic]`, `[tools]`, `[state]`.
## Verification recipe
End-to-end smoke test for a fresh install:
1. Create RC lowpass netlist (R=1k, C=100n → fc ~1.59kHz)
2. Add `.ac dec 100 1 1Meg` directive
3. Run it with `run_experiments`
4. Read it back with a `bode_filter` recipe — verify the -3dB point near
1.59kHz
5. Change R to 10k (fc → ~159Hz)
6. Re-run and re-measure — verify the bandwidth shifted
7. Pull a custom library in with a `.lib` / `.include` directive and use one
of its models in a new circuit
If a step fails, check `simulator.path` in TOML and confirm
`ltspice-mcp.toml`'s `[security] allowed_paths` includes your working
directory. The planned doctor tool (see Roadmap) will automate these
checks.
For how the project tests, see `docs/TESTING.md`. It also describes the
absence-class bugs the tests once missed and the mechanisms added to catch
them (inverse-op closure, the archetype build battery, task-down coverage,
blind-artifact judging).