CLAUDE.md · git:20261007.808fea0 · 2026-10-07 · sha256 702261224101a4d5
CLAUDE.md git:20261007.808fea0A
Immutable. This exact content never changes and is served at /api/v1/blob/702261224101a4d5.
# CLAUDE.md
Guidance for Claude Code (claude.ai/code) working in this repository.
Start with `docs/DESIGN.md` (architecture and rationale). This file is the working map of the source: what lives where, and which patterns are load-bearing. Setup is `uv sync` on Python 3.11 or newer; a simulator is optional for most of the suite (recorded `.raw`/`.log` fixtures cover the parse paths offline), `ngspice` on PATH enables the live end-to-end tests, and the LTspice integration tests are opt-in behind an environment flag.
## Windows is the primary platform
Most users run this server natively on Windows, where LTspice lives. Development happens on WSL2 and the gate on Linux CI, so a change that works here can still be broken for the people the project is for. Every path, file write, signal, process, newline and encoding decision has to hold on Windows: `os.open` without `O_BINARY` is text mode there, `signal.SIGKILL` and `os.killpg` do not exist, symlinks need a privilege, `\r\n` is the platform newline, the platform codec is the machine's ANSI code page (cp1252 on a Western European install, cp936 or cp932 on a Chinese or Japanese one, and never UTF-8 unless Python is told so), and a drive letter does not map to `/mnt`. The `/mnt` mapping, `wslpath` and the Windows-side temp directory are WSL interop, taken only when `is_wsl()` is true, never on Windows itself. A review of a change touching any of those asks "does this hold on Windows"; the release gate includes a native Windows run of the suite (`docs/TESTING.md`, "Platforms"); and a test that cannot hold on a platform skips there with the reason, never fails.
## Provide scaffolding for the agent's engineering work
The agent chooses the measurement, derives circuit-specific quantities, selects statistical assumptions, and constructs the design/search strategy. The server supplies reliable simulator access, validated circuit operations, reproducible jobs, traceable data, and composable numerical primitives. A calculation an agent can implement reliably through the existing API or `run_code` is not a missing server feature merely because it has no dedicated tool or recipe.
Before adding functionality, demonstrate the engineering task through the existing programmable interface and identify the missing primitive. Keep simulator-specific behavior and numerical correctness obligations such as step identity, axis alignment, time weighting, units and missing-data accounting in tested shared code. Circuit-specific formulas, reports and optimization policies belong in agent-authored code unless evidence establishes a reusable contract that the primitives cannot adequately serve.
Use this boundary when auditing existing functionality too: record the real task, the programmable alternative, the correctness burden owned by the server, and the appropriate home (core primitive, simulator adapter, example/guidance, or agent code). Audit existing recipes, reports and search proposals against the same rule as new features. Do not remove useful behavior solely because its formula is simple; demonstrate the replacement workflow and preserve its numerical guarantees before proposing consolidation or removal. Caller-supplied statistical assumptions must remain distinguishable from PDK-provided models and from measured process data.
## Project Overview
MCP server exposing SPICE circuit simulation to LLMs. LTspice is the primary simulator; ngspice/qspice/xyce are supported but secondary. Built on the low-level `mcp.server.lowlevel.Server` API (not FastMCP) with spicelib as the simulation backend. The same engine is also importable in-process as `ltspice_mcp.api`.
## Commands
Standard `uv` invocations (`uv sync`, `uv run pyright`, `uv run ruff check --fix src/ tests/`, `uv run pytest tests/ -v`) work as expected; the dev tools are declared in `pyproject.toml`. The non-obvious ones:
```bash
# Debug a single failure (disable parallelism for readable output)
uv run pytest -n0 tests/test_pathutil.py::TestName::test_case -v
# Run the server without the console script
python -m ltspice_mcp
```
No Makefile. CI: `.github/workflows/publish.yml` (test + publish to PyPI on version tags).
`pytest-xdist` is available, but the suite runs serially by default. Pass `-n auto` to parallelize locally when you do not need deterministic output order or debugger attachment.
**Working practice** (these cost real time when ignored):
- **Don't use `uv run python -c` to explore an API.** Read the source with `Read`/`Grep` instead — a throwaway interpreter session answers one question and leaves nothing behind.
- **A regression test must fail before the fix and pass after.** If it never failed, it does not pin the behavior; run it against the unfixed code to confirm it fails there.
- **Test through real code paths, never a bypass**, and assert on real values — a test that stubs the mechanism it claims to cover passes for the wrong reason.
- **Do not re-run a passing suite to feel sure.** Re-run it only after the tree changes.
- **Wait for what the assertion reads, never for a side effect or for time.** Work the server started is awaited through its owner — `await runner.settled(job)`, `await state.settled()` — and anything else (a thread, a fake simulator's callback, another process) through an event or a handshake. A timeout on a wait in a test is a cap on a hang (`LIVENESS_S` in `tests/conftest.py`), never a claim about speed. `tests/test_test_hygiene.py` checks the mechanical half; `--jitter-seed=N` reruns the suite with thread hand-offs, timers and process starts perturbed the way Windows perturbs them (`tests/schedule_jitter.py`), and CI runs two seeds. A test that fails only under a seed is a race, not noise.
`docs/TESTING.md` is the testing-practice doc: the absence-class blind spot (a missing capability, or one unusable for an input class nobody fed it) and the mechanisms that catch it — inverse-op closure (`test_dispatch.py::TestOpInverseClosure`), the archetype build battery (`test_circuit_asc.py::TestArchetypeBuildCoverage`), task-down coverage, and blind-artifact judging. Read it before adding a tool or an op.
**LTspice's behaviour is recorded, not assumed.** `tests/fixtures/ltspice_recorded/` holds what LTspice 26 and LTspice XVII each wrote for the inputs listed in `inputs/cases.toml`, which is the inventory of every LTspice behaviour the code models. `tests/test_recorded_ltspice_*.py` hold the server to those files on any machine; `scripts/record_ltspice_fixtures.py` records again on Windows, and the opt-in tier compares an installed build with what is committed. Code that newly encodes "LTspice does X" needs an input there and a test against its recording, never an expected value worked out from the assumption itself. The two builds differ more than their version numbers suggest (log and export encodings, error wording, what `.op` prints), so a claim checked on one says little about the other. See `docs/TESTING.md`, "Recorded LTspice behaviour".
## Comments, docstrings, commit messages, and branch names
These are read by people outside this project — keep internal shorthand out of them. Do **not** use, in code comments, docstrings, commit messages, or branch names: severity codes, internal codenames for findings, backlog item numbers, or internal batch/version labels. Name a branch for what it changes (`fix/asc-export-lock`). Describe the actual behavior, condition, or bug in plain technical terms instead. Internal planning notes may use that shorthand; shipped code, git history, and branch names may not.
## spicelib bugs
spicelib is a pinned third-party dependency we cannot fix in place, so we work around its bugs and remove the workaround once upstream is fixed. **Whenever you hit a spicelib bug or limitation** — a wrong parse, a hang/infinite loop, a silently dropped field, an unapplied header value — **record it in `docs/spicelib_bugs.md`** as a self-contained, upstream-ready section: summary, affected code + version, reproduction, impact, proposed fix, a suggested upstream test, and a cross-reference to our workaround and the test that pins it. Do this even when you also ship a workaround — the file is the record of what to delete once upstream lands, and the reproduction is what lets someone confirm the fix. Follow the format of the existing entries.
## Architecture
All source lives under `src/ltspice_mcp/`.
```
MCP Protocol Layer server.py — lifespan, dispatch, request routing
resources.py — MCP resources & URI templates
Tool Layer tools/*.py — tool definitions + handlers
Core Logic Layer lib/*.py — see below
Config/State config.py, state.py, errors.py
```
Key `lib/` modules:
- `services.py` — application-level service layer shared by tools and resources. Owns job resolution, cached result loading, and reusable extraction logic. Sits between MCP adapters and pure parsers. **Runs are case-addressed:** `resolve_experiment_run(job_id, ...)` / `experiment_run_context(job, ...)` resolve one case of an experiment to an `AnalysisSource` (raw, log, deck, dialect, identity), gated on the job being terminal and the case having produced. Case raws bypass `safe_path` — they are trusted server artifacts, and a reloaded job's raw can live outside `allowed_paths` (a WSL temp dir).
- **SPICE lexer/validator** — `spice_lex.py` (foundation netlist lexer → `list[SpiceCard]` tokens), `spice_lex_ops.py` (cross-card transform passes), `spice_lex_views.py` (typed views over cards), `spice_validator.py` (pre-flight directive + arity validation). The `.cir`/`.net` read / list paths and `verify_circuit`'s arity checks run on this pipeline, not spicelib's `SpiceEditor`. The dangling-node, directive-reference and bias-topology rules (`validate_netlist_dangling_nodes`, `validate_netlist_directive_refs`, `validate_netlist_bias_topology`) run under `verify_circuit`'s `quality` check on a netlist (`_NETLIST_QUALITY_RULES` in `tools/verify.py`); the `syntax` check runs `validate_directive` and `validate_netlist_arity`.
- **The store** — `store.py` owns the on-disk layout: **every path the server writes is a method on `Store`**, so the set of directories it can create is the set of methods there. Read its module docstring for the tree. Nothing the server keeps goes beside a user's circuit: export snapshots and default plots are in the working-dir store, and the recent index and circuit locks are in `user_home()` (`%LOCALAPPDATA%\ltspice-mcp` on Windows). `LTSPICE_MCP_STORE_DIR` moves the whole store to one directory per working directory under it (`relocated_store_root`), so the store need not be inside `allowed_paths`: `stage_deck` stages a deck generated from its `origin` as if it stood beside that origin (admitted through it, relative includes resolved from its folder), and a compared export's includes resolve from the named file's folder (`ReferenceNetlist.base_dir`). Three rules that are not obvious: run artifacts group under `runs/{job_id}/` (staged decks included) while the *runner's* output folder stays the one `runs/` root, because `RunnerManager` caches a runner per output folder and a runner owns the `max_parallel` semaphore — the grouping rides on `run_filename`, which the simulator layer joins onto the unchanged folder; the WSL rule that moves LTspice's whole tree to a Windows-native disk is decided once in `Store.artifact_base`; and one `store_version`, stamped in `store.json` at the root, covers every record the store writes (job, request index, circuit index, cancellation, result set, attached-analysis snapshot, recent index) — bump it as one. `tests/test_store_layout.py` pins both ends: what a real session creates, and what the `Store` API can create.
- **Job subsystem** — `job_types.py` (the status vocabulary, re-exported from `state` to break import cycles), `job_registry.py` (in-memory registry + disk persistence + `preload_recent`; `get_or_load` is the one route from an id to a job, and `_adopt` is the one place that decides whether an off-loop caller may register what it read), `job_lifecycle.py` (declarative status state machine, `transition()`, and `LiveJob`: a job this process admitted, holding the events its waiters wait on and its coordinator task. Only `JobRegistry.reserve` makes one, a live job's status changes only through `LiveJob.transition` (`test_job_lifecycle.py` pins the call sites), and every wait goes through `JobRegistry.wait`, which waits on the live job, re-reads another process's record, or answers from a record nothing here runs). Every job is an experiment: the per-circuit job sidecars releases before 0.6 wrote are not read, so an id only one of those files knows is simply not found.
- **Experiment subsystem** (backs every job the server runs) — `experiment_types.py` (the domain dataclasses `ExperimentJob`/`ExperimentCase`/`Completeness`; `ExperimentJob` is data only, because the store reads records back as the same class and a copy would carry signals nothing sets), `experiment_store.py` (the record's shape: serialization, restart reconciliation, the request index behind durable idempotency, and the per-circuit index behind `jobs(list, circuit=...)`), `experiment_runner.py` (the standalone coordinator: per-case futures, cancel gates, retained permits on unconfirmed kills), `variations.py` (variation expansion, target ladder param→ref→@model), `deck_staging.py` (manifest staging; asks the store where a simulator's decks may go), `lint_rules.py`. Cases run on the job-agnostic `submit_netlist` primitive in `runner_base`.
- **Nested targeting and native statistics** — `hierarchy.py`/`hierarchy_expr.py` resolve supported static instance identities and numeric facts; `instance_targeting.py` clones selected ancestry and carries source lineage through case materialization. `native_inputs.py` binds final cases to captured original PDK bytes and physical-MOS coverage, `pdk_native.py` validates the pinned statistical profile and seed/driver contract, `native_execution.py` prepares Store-owned files before claim, and `native_records.py` persists the typed facts. Native statistical families reuse the experiment coordinator and shared permits; they never use caller Pelgrom coefficients or alter protected PDK models.
- **Analysis result plumbing** — `recipes.py` (the typed recipe union behind `analyze_results`), `result_store.py` (immutable result sets whose manifests identify each raw+log by size and mtime, and gain content digests once a cursor hands the set out), `cursor_codec.py` (the checksummed cursor a surface resuming into stored work mints; also used by `pin_legend.py` and `inspect`), `pagination.py` (the page shape every list surface returns, and the plain `"o:<offset>"` cursor a recomputed listing uses).
- **Schematic rendering** — `schematic_scene.py` (`.asc` → absolute-coordinate `Scene`), `schematic_renderer.py` (SVG), `raster.py` (PNG via the optional `raster` extra), `netlist_graph.py` (structural comparison behind `verify_circuit`), `netlist_diff.py` (the node-blind `structural_diff` delta, and the LTspice export boilerplate it leaves out), `pin_legend.py` (per-component pin/net table).
- `runner_base.py` — the shared spicelib runner scaffolding (`submit_netlist`, outcome classification, token-scoped kill) that `experiment_runner` builds on; `montecarlo.py` is the pure perturbation engine `variations.py` draws from
- `deck_prep.py` — getting one deck ready to run: path resolution, the `.asc` → `.net` export and its per-schematic lock, and the content-addressed deck snapshot. Distinct from `deck_staging.py`, which stages a whole include manifest for an experiment
- `projection.py` — dotted-path field projection (`keep_plan`/`project_row`), shared by `analyze_results` and `run_experiments`
- `schematic_ops.py` — the `.asc` edit engine: op models, appliers, geometry, net tracing. Every name another module imports from it is public
- `runner_manager.py` — centralized runner lifecycle (see Key Patterns)
- `simulator.py` — simulator detection, WSL/Wine selection
- `simulator_build.py` — which build ran: the executable a job records at submission (path, size, mtime, SHA-256; the last word of `spice_exe`, so never `wine`), and the version each case's run named in its own output (LTspice log banner, ngspice `.exe.log` console banner, raw `Command:`), read with bounded head reads. A replay is refused when the executable the request would launch now is a different build: `experiment_runner.verify_replay` is the one check both replay routes run (the tool's lookup before the request gate, the coordinator's under it), decks and executable together
- `ltspice_wsl.py`, `wsl.py` — WSL path conversion and interop
- `ltspice_windows.py`, `hidden_desktop.py` — LTspice on native Windows. LTspice opens a window for every batch run and export and holds the keyboard focus for as long as it lasts, whatever show state it is started with, so `SIMULATORS["ltspice"]` there is a subclass (still named `LTspice`) whose `run` and `create_netlist` start spicelib's own command lines on a desktop of the process's own (`hidden_desktop.shared()`, closed by `SessionState.shutdown`). `subprocess` cannot name a desktop, so `hidden_desktop` calls `CreateProcessW` itself: only the streams it is given are inherited, and each process is in a kill-on-close job before it runs. Nobody can answer a message box there, so `HiddenDesktop.run` ends a program found waiting on one (the same box on two looks running, `BoxWatch`) and raises `DialogError` with what the box said. `[simulator] hidden_desktop = false` turns it off, and anything that newly launches LTspice on Windows goes through here; the audited recovery launch in `controlled_ltspice.py` is the one that does not yet. The fixture recorder uses the same launch
- `ltspice_bridge.py`, `ltspice_window.py` — a sheet someone has open in LTspice. LTspice reads a sheet once, so a window holds a copy of its own that a write to the file does not change and that the window's next save writes back over it. `OpenWindows` (on `SessionState`, built by `detect` from the detected executables) reaches the windows through the MCP bridge LTspice ships from 26.1, `ltspice-mcp-bridge.exe` beside `LTspice.exe`: `edit_schematic` refuses to commit under a window whose copy differs from the file (`open_window_differs`) and after a commit replaces the window's copy with the committed sheet. `BridgeSession` is the stdio client, bounded as a whole by a deadline that ends the process. The bridge starts an LTspice of its own whenever it has none, on any request that needs one, and nothing in it turns that off. So it is a program that can launch LTspice and is started like one: on `hidden_desktop.shared()`, whose `start` gives it the pipes it is spoken to through and puts it in a job the session ends, so an LTspice it launched has its windows off the user's desktop and is gone with the session. `bridge_command` also names an LTspice that does not exist, which makes the launch fail before it starts; that is the second line, because it is a failure the bridge reports and not a mode it offers, and the only one where there is no hidden desktop. Two things open something in a window, each only for a caller who passed `in_ltspice`: `OpenWindows.open_sheet` (behind `verify_circuit`) opens a sheet or netlist and puts it in front, handing back the window's own copy when it was already open so the reply can say whether that is the file; `OpenWindows.show_results` (behind `plot_waveform`) opens a finished run's results file. `sheet_content` is the comparison, of content and not text, because LTspice hands back its own writing of a sheet. `inspect(kind="open_in_ltspice")` lists what the windows have open and which document is in front (`OpenWindows.designs`), with a sandboxed sheet's digest and whether its window differs from the file (`file_difference`, the rule the edit refuses on); the window's copy is compared and never returned, and a document outside the sandbox is named and not read. Native Windows only; `[schematic] sync_open_window = false` turns it off. What the bridge and a window do is recorded in `tests/fixtures/ltspice_bridge_recorded` by a recorder of its own (`tests/ltspice_bridge_recorder.py`), and `tests/fake_ltspice_bridge.py`, the stand-in the suite runs, is replayed against that recording
- `plot_settings.py` — the plot settings file (`.plt`) LTspice loads as it opens a results file of the same name, which is what makes a run opened in an LTspice window show its traces in place of an empty plot. `write_beside` writes the traces only, per pane, in a section named as the results file names its plot (`PlotDescriptor.original_plot_name`); LTspice scales the axes itself. A file a person saved from LTspice carries axes and a grid too, which is how `holds_only_traces` tells theirs from one written here and why theirs is never replaced. That LTspice draws what such a file names was looked at on 26.1.1 and cannot be asked of it; the shape of the files it ships with its examples is in the bridge recording's manifest
- `simulator_docs.py` — the reference documents a simulator's vendor installs with it, behind `inspect(kind="simulator_docs")`: for LTspice 26.1 and later, the Markdown files in the `reference` directory beside its library (shortcuts, menus, the schematic format, `.MEAS`), which LTspice's own MCP server also serves. Read from the install when asked and never packaged, since they are the vendor's and the installed copy describes the installed build; served in sections cut at second-level headings, so a long one pages through the ordinary cursor. A name is looked up only among the files the directory lists. They are not part of the guide, which is one text on every machine
- `metrics.py` — the numeric core behind every `analyze_results` recipe: one function per discriminant, each taking a resolved `AnalysisSource` explicitly, with `METRICS` mapping recipe class to function. `tools/analyze.py` looks a recipe up there rather than branching on its type, and a recipe class with no entry fails at import. Also home to the readers those functions share — axis and window handling, the AC signal loader, the log relay, operating-point device matching, .MEAS aggregation
- `ac_analysis.py`, `signal_analysis.py`, `ac_structure.py` — pure-function analysis primitives for frequency-domain (.AC) and transient (.tran) `.raw` data; the metric functions in `metrics.py` are what put results on the wire
- `raw_parser.py`, `log_parser.py` — simulation `.raw` / `.log` result parsing
- **Contained result reads** — every `.raw`/`.log`/console read goes through `parser_service.load_artifacts_sync`. The parent authorizes the paths and stats the sources (`file_stamp.py`: device, inode, size, modification and change time; the NTFS change time on Windows, where `st_ctime` is the creation time). A source still carrying the stamps recorded when its content was last parsed, once those times are older than a timestamp tick, is answered from `result_cache.ResultCache` without a process; anything else goes to a guardian + worker tree (`parser_process.py`, `parser_bootstrap.py`, `parser_worker.py`) under a deadline and memory limit that captures, hashes and decodes. The session keeps one tree between parses (`WarmParser`, owned by the `ResultCache` and closed by `SessionState.shutdown`): a call's results are read only once the worker has replied and is again the tree's only process, and the tree is replaced after `WARM_CALLS` calls, half of `WARM_LIFETIME_S`, `WARM_IDLE_S` idle, or any failure. `run_parser_sync` without a `warm` parser is a one-call tree, closed before its results are read. Waits there are on the OS, not a clock: replies and exits on reader threads, a pidfd and `SIGCHLD` in the Linux guardian, the Job Object's completion port on Windows. An unconfirmed close closes admission (`retain_parser_slot`), and the stamp path with it; `tests/conftest.py` closes every tree a test left (`parser_process.close_all`). A parser process imports only what it needs before it has content to decode (`test_parser_worker.py` probes this), so keep heavy imports out of the modules it loads
- `library_manager.py`, `library_parser.py`, `encoding.py` — component library handling + library/netlist encoding detection
- `component_value.py` — element-class-typed dispatcher behind the `set_component_value` op
- `cache.py` — `FileCache` for editor and result instances
- `model_fields.py` — reads a Pydantic field's type, default, bounds and caller-facing name in one place; shared by the API's `reference()` and the tool-side branch index, so an argument reads the same through both interfaces
- `pathutil.py` — path security (`safe_path()`, `resolve_safe_path()`); `filelock.py` — cross-process advisory file locks
- `recent.py` — global recently-touched-circuit index (`recent.json`); backs the startup job preload
- `symbol_geometry.py`, `geometry.py` — .asy symbol parsing (pin positions, rotation transforms, bounding boxes) + shared 2D / bbox helpers
- `guide.py` — the agent-facing guide, and the only copy of it: a core every session reads (`assets/guide/core.md`) and sections beside it, one file each, listed in `SECTION_ORDER` (a test pins it to the files present). A section's front matter gives its `name`, a `description` saying when to read it, and its `kind`: a `topic` (reference) or a `task` (a playbook for one kind of job). The core ends in an index generated from that front matter, grouped by kind, so new guidance — including a new task playbook — is a file here plus its name in `SECTION_ORDER`. The Claude Code plugin ships one skill, `skills/spice-guide`, which only points a session at the guide; do not add guidance to `skills/`, because a second copy drifts (the old per-simulator skills had, more than once). One module behind three doors that must serve the same text: `inspect(kind:"guide", section?)`, `Api.guide()`, and the `spice://guide` resources. The server instructions send a session there first, and the first tool reply of a session that has not read it carries one reminder (`server.py:_with_guide_reminder`, keyed on `SessionState.guide_read`), in the text and in `structuredContent["hint"]`
- `background.py` — `BackgroundTasks`, the owner of work the server starts and does not await itself: the runner's submission pipelines and late-exit follow-ups, the registry's record writes, the session's recent-index touch. `settled()` waits until none is left, counting work started while it waits; `ExperimentRunner.settled(job)` and `SessionState.settled()` build on it
- `observability.py` — structured job-lifecycle events on the `ltspice_mcp.events` stderr logger (the server's only log channel: the MCP logging capability is deprecated as of 2026-07-28 and is not served). `configure_stderr_logging` installs that channel, and is what both the server lifespan and the detached owner call, so every process writes the same format at the level `[logging] level` names
Self-describing helpers not listed above (`format.py`, `sweep_utils.py`, `desktop.py`, `plot_html.py`, `plot_svg.py`) do what their names say — read them when you need them.
### Tool Module Convention
Registered tools use a decorator-based registry via `@registry.tool()`:
```python
@registry.tool(
name="foo",
title="Do The Thing", # required: the label a client shows a person
description="...",
input_model=FooInput, # subclass of ToolInput (Pydantic)
annotations=RO_ANNOTATIONS, # or custom ToolAnnotations
output_schema={...}, # optional: JSON Schema for structuredContent
)
async def handle_foo(args: FooInput, state: SessionState) -> types.CallToolResult:
...
```
`tools/__init__.py` simply imports all tool modules to trigger registration, then exposes `get_tools()` which delegates to `registry.get_tools()`. `SessionState.create()` calls this during lifespan init. `get_tools` raises if the registry resolves to zero tools — a working handshake advertising nothing is the failure mode that guard exists for.
**Registered tools and delegated handlers.** Only the eight registered tools carry `@registry.tool`. A handler one of them delegates to declares its own contract with `@declare_output_schema(...)` (same schema argument forms as `registry.tool`): the contract belongs to the handler, not to its registration, and the test suite's conformance hook validates every emission at the first stack frame carrying one — so a delegated emission is checked against the delegate's schema, never the delegating tool's envelope. A new delegation without a declaration fails `test_conformance_hook_armed.py`'s closure test. A handler with no caller at all is dead: the pre-0.6.0 tool handlers were kept as a rollback seam through 0.6 development and are gone, and so is the adapter layer `analyze_results` used to reach its numbers through — the recipes call `lib/metrics.py` directly. Both come back only from git history.
**Shared helpers in `_base.py`**: `json_response()` and `format_response()` for building `CallToolResult`; `StrictModel` as the Pydantic base for strict validation config; `ToolInput(StrictModel)` as the base for top-level tool input models; `RO_ANNOTATIONS` for read-only tools; the shared response `Envelope` (`outcome_of`, `outcome_schema`, `FINDING_SCHEMA`, `failures_schema`, `page_schema`); the shared `RenderPolicy`/`CompareSpec` argument models; and `PIN_SCHEMA`/`BBOX_SCHEMA` for reusable output schema fragments. The offset-paged envelope itself (`{items, total, returned, truncated, next_cursor}` and the `o:<offset>` cursor) is `lib/pagination.py` — below the tool layer, because `analyze_results`' pages and the experiment records use it too; `lib/pin_legend.py`'s checksummed, binding-scoped cursor is a separate, stricter contract.
**`reference_index.py`** (in `tools/`, not `lib/`, because it walks the registered tools' input models and `lib` may not import `tools`) builds the per-branch table behind `inspect(kind:"reference")` — every analysis recipe, schematic op, variation kind, query kind, check and job action with its fields — derived from the models, with a completeness test in both directions (nothing missing, nothing indexed that cannot be called). The search ranks branch name over plain synonym over field name over prose; with no query it returns the table of contents. It is how a session on the `compact` listing learns a branch's arguments.
**`_schema.py`** holds everything about the JSON Schema a tool *publishes*, so a change to how schemas are shrunk is not a change to the module every tool imports: `ToolInput` and `build_input_schema` (one publishing descent that drops titles, null defaults and discriminator mappings, then type-keyword compaction and the shared-fragment `$defs` hoist), `schema_from_typeddict` (the output-schema generator), and `strip_argument_descriptions` (the one deliberate prose filter, applied only on the `compact` listing — a branch whose published properties are all `const`s keeps its description, because it has no argument structure left to read). Every pass is one `edit` callable driven by `_rewrite`, the single structural descent that keeps an argument named `title` or `default` a field; every *other* pass is structural — it changes how a schema is spelled, never what it says. `_base.py` re-exports only the names it uses itself.
**Every tool declares a `title`** — the short label a client shows a person in place of the wire name. It is a required argument to `registry.tool`, so a new tool cannot ship without one, and it rides both listing modes (the compact listing drops what an argument means, never how the tool is named).
**The advertised definition is the source definition minus `outputSchema`.** On the default `full` listing no prose filter sits between them: every description a model declares is a description the client is shown, so reading the models tells you what ships. The `compact` listing is the one exception, and it removes descriptions rather than rewording them — `strip_argument_descriptions` takes every per-argument description off the published copy, so the source is still the record of what a full-listing client is shown and of what a compact-listing client would have been shown. What keeps the surface small is that each description is written short — one or two plain sentences carrying the unit, the sign or direction convention, the default, and the interaction with a sibling field — with the depth in `docs/design/mcp_surface.md` or a section of the guide and a pointer left behind that names it (`guide section 'signals'`; `tests/test_guide_delivery.py` checks every such pointer resolves). `tests/test_consolidated_contracts.py` pins both halves: the advertised descriptions equal the source ones, and each tool's serialized definition stays under its size bound. `outputSchema` is the one thing dropped on the way out (it was the largest block on the surface and a response teaches its own shape); the registered definition keeps it for the doc gates and the conformance hook.
**Output schemas**: Tools that return `structuredContent` (via `format_response()`) declare an `output_schema` (or an `output_model` TypedDict) for client introspection. Every tool on this surface does, so every one declares one. A dispatcher that delegates to another handler may omit it — the sub-handler's structuredContent carries the shape. A tool with an `output_schema` must return `structuredContent` on every code path, text-only replies included.
**Every registered tool's `output_schema` must be an object schema at the top level.** A bare `oneOf`/`anyOf` makes strict MCP clients (Claude Code included) reject the entire `tools/list` response, disabling every tool on the server — pinned by `test_dispatch.py::TestSchemaPostProcessing::test_output_schema_top_level_is_object`.
**Structured self-sufficiency**: structured-aware clients (Claude Code included) render only `structuredContent` when it is present and drop the text channel entirely, so the data dict must carry everything the caller needs to act on. Any caller-guidance in a `format_response()` text (hints, referrals, recovery steps, caveats) must be mirrored into `data` — conventionally an optional `hint` string key declared in the `output_schema`. The text channel is presentation only.
### Schematic Editing (.asc)
Direct editing of LTspice `.asc` schematics is a first-class feature. The MCP surface for it is `edit_schematic` (typed op batch), `inspect` (symbol/net/components reads), and `verify_circuit` (lint/export/compare/render). The shared implementation lives in **`lib/schematic_ops.py`** — extension-based dispatch picks `AscEditor` or `SpiceEditor` automatically. Every name another module imports from it is public; read its module docstring for the map. What lives there:
- `plan_connect_route` wires two pins by reference (e.g., `M1.D` → `M4a.D`) with waypoint routing, behind the `wire_pins` op. Validates before writing: refuses diagonal wires, pin collisions, wire junction overlaps, and a same-instance tie LTspice would drop. Warns on long runs and bbox crossings.
- The `add_component` op returns pin positions (with direction), bounding box, and overlap warnings; `inspect(kind:"symbol")` provides the same geometry non-destructively for pre-placement planning.
- `handle_trace_net` (in `tools/inspect_tools.py`, behind `inspect(kind:"net")`) reports every pin/label/wire vertex on the net at a pin/`net:NAME`/`(x,y)`, flagging multi-label shorts. Built on `schematic_ops.net_partition`, the union-find that also backs `trace_nets`.
- There is no session-side undo. `edit_schematic` is revision-guarded and transactional (a failed batch writes nothing), so recovery from a committed edit is the caller's own copy of the sheet.
- Every mutation is an op in `edit_schematic`'s batch, applied by `apply_op_inplace` under `run_op_batch`; `post_op_warnings` runs the validation pass over the mutated editor afterwards, and `collapse_result_warnings` folds an advisory repeated across ops into one entry with a count. On an existing sheet the pass and the label-only pins are also read before the ops run, and the reply lists only the rows the batch introduced or named (`_split_by_edit` in `tools/schematic_edit.py`); the rest are counted under `preexisting` and paged by the `preexisting` view, never dropped.
- What is left in `tools/analysis.py` is the `plot_waveform` tool and the two artifact writers `analyze_results` shares with it (`build_waveform_csv`, `build_plot_file`); every recipe's numbers come from `lib/metrics.py`.
- All path-taking surfaces use `"path"` as the file parameter name.
AscEditor requires `.asy` symbol library files. Platform handling in `server.py:_configure_asc_editor()`:
| Platform | How symbol paths are resolved |
|-|-|
| Windows native | `AscEditor.prepare_for_simulator()` (spicelib built-in) |
| Linux native (LTspice via Wine) | `AscEditor.prepare_for_simulator()` (spicelib handles Wine paths) |
| WSL + LTspice on Windows | `wsl.get_ltspice_lib_paths()` resolves `%LOCALAPPDATA%` via `cmd.exe` |
| Any platform without LTspice | No .asc support (no .asy symbol files available) |
Users can override via `[schematic] symbol_paths` in TOML or `LTSPICE_MCP_SYMBOL_PATHS` env var.
### Key Patterns
- **Heavy blocking work is offloaded with `asyncio.to_thread`**: the MCP SDK dispatches each request as its own task on one shared event loop, so anything that blocks the loop stalls every in-flight request (including a cancel). Slow filesystem work is therefore awaited via `asyncio.to_thread` at its call sites — raw parses (`services.load_raw` is async; `load_raw_sync` is the worker-side residual), batch raw/log loops, the recent-index lock + durable write (`SessionState.note_recent_circuit`), WSL `cmd.exe` interop (`Store.artifact_base`, reached off-loop by the staging route), and whole MCP resource reads (`server.py:read_resource`). Two invariants: response building (`format_response`/`json_response`) stays in the handler coroutine (the test suite's schema-conformance hook attributes emissions by walking the handler frame on the current thread), and mutable cached editors are touched only on the loop — editor parses/mutations stay inline by contract (see the contract comment in `tools/_base.py`). Simulation runners still use their own background threads for long-lived simulator work. Do not reintroduce inline heavy work citing thread-deadlock concerns — that old claim had no recorded basis and `tests/test_loop_responsiveness.py` pins the offloaded behavior.
- **Background work has an owner, and the loop publishes.** A task nobody awaits goes through its owner's `BackgroundTasks.spawn` (`lib/background.py`), never a bare `create_task`, so `settled()` can answer for it; a direct spawn elsewhere must be listed with its owner in `tests/test_test_hygiene.py`. A worker thread computes and returns; what another reader can see (an observation, a removed artifact) is applied on the loop, in an order that never shows half a step — a stopped case's progress is recorded before its raw is removed.
- **Offload is not a wedge guard — bounding untrusted work is a separate obligation.** `asyncio.to_thread` stops a *slow* operation from stalling the loop; it does nothing for a *runaway* one, because a CPU-bound loop inside a third-party parser holds the GIL and stalls the loop from any thread. So treat every artifact a simulator hands back (raw/log) as untrusted input: a parse through a dependency you don't control can hang or loop indefinitely on a shape you didn't anticipate, and the only real protection is a hard bound (wall-clock or subprocess) that fails that one call instead of every session. Offloading heavy work and bounding untrusted work are orthogonal — a hot parse on a shared path needs both, and "we moved it to a thread" is not evidence it's safe.
- **Path security**: All user-provided paths go through `safe_path()` → `resolve_safe_path()`, which validates against `config.allowed_paths`. Raises `PathSecurityError` on violation.
- **Lifespan context**: `server_lifespan()` creates `SessionState` (config + detected simulators + `JobRegistry` + the tool dispatch built once from `tools.get_tools()`) and sets `server.instructions` to name the detected simulators. Each handler is a `(ctx, params)` function registered through the `Server(...)` constructor, and reads state from the context it is handed: `ctx.lifespan_context["state"]` (`server.py:_get_state`). `main.py` drives `serve_dual_era_loop` with no initialization options, so the SDK builds them per `initialize` from the live server — the same live state its `server/discover` handler reads on 2026-07-28 connections, which have no handshake at all.
- **Job lifecycle & persistence**: `SessionState` delegates all job state to `JobRegistry` (`lib/job_registry.py`); `state.all_jobs`/`state.add_experiment_job`/etc. are thin delegators. Experiments round-trip through `experiment_store`; `preload_recent()` reloads recently-touched circuits' records at startup. Resolution has one route — `services.resolve_job` asks the registry, the registry asks the store — so "not in memory" and "not on disk" are one answer rather than a fallback chain each caller re-assembles. Status transitions go through the `job_lifecycle.transition()` state machine. On shutdown, `cancel_running()` cancels this process's own experiments and `drain_pending()` flushes persistence.
- **Structured errors**: Use the hierarchy in `errors.py` (PathSecurityError, NetlistError, SimulationError variants). `server.py:call_tool` turns every failure — a rejected argument, a sandbox violation, an unexpected exception — into a `CallToolResult` with `is_error` set and the message on the text channel, because that is what the calling model reads and corrects itself from; a raised exception would reach the wire as a JSON-RPC error it cannot act on. `server.py:_ERROR_HINTS` maps an error type to one recovery hint, appended to the message and mirrored into `structuredContent["hint"]`; the hints name only tools the client can see. Two failures are the exception, and raise `MCPError` with the invalid-params code instead (2026-07-28 dropped the separate resource-not-found code): a failed **resource read**, which has no result to carry a message, and a **tool name the dispatch map does not have**, which has no tool to attribute a result to — that message names the unknown name and lists the tools that do exist.
- **Log diagnostics**: `log_parser.py:extract_log_diagnostics()` extracts structured warnings and errors from LTspice log files (parse errors with caret pointers, Fatal Error, convergence messages, etc.). Consumed by `runner_base` at run completion, by `services` for the run-level solve-failure relay, and by `lib/metrics.py` and `tools/analyze.py` when reading results — so a failed run surfaces its errors instead of silently returning empty results.
- **Runner lifecycle**: `RunnerManager` (`lib/runner_manager.py`) owns the experiment runner instances. Accessed via `state.runners.get_experiment_runner(loop, simulator_class, output_folder)`. Runners are cached per (kind, simulator class, output folder) with a small LRU cap — a per-run simulator override depends on a second simulator's runner not evicting the first's in-flight launch permits/cancel state, so eviction skips runners reporting `has_active_work()` (the cache may transiently exceed the cap). Only an event-loop change invalidates everything. **`max_parallel_sims` is the runner's cap, not a job's:** `RunnerBase` holds one `asyncio.Semaphore(max_parallel)` and every case acquires a permit for the life of its simulator process (`acquire_launch_slot`/`release_launch_slot`), so concurrent `run_experiments` calls share one quota instead of each getting a private one. A request's own `execution.max_parallel` is a second semaphore layered on top that divides that job's share — it can only lower it (`ExperimentRunner._case_capacity` clamps to the runner's cap), which is why `tools/experiments.py` passes the runner `config.max_parallel_sims` and never the request's value. Cancel resolves the coordinator by ownership (`get_experiment_runner_for(job)` — the cancel state lives only on the instance that launched the job). Result parsing likewise follows the job: `services.dialect_for_job`/`raw_dialect_for` derive the raw dialect from the simulator the job ran on, not the session default. Never create runners directly.
- **Parallel sessions**: independent server processes may share a working directory; they coordinate only via the filesystem. Three mechanisms (all pinned by `tests/test_parallel_sessions.py`): (1) every circuit-file mutation/export runs inside `circuit_file_lock` (cross-process `file_lock` on `Store.circuit_lock`, one lock file per circuit in the per-user home keyed by the digest of the resolved, case-folded path — never beside the circuit and never per working directory, so sessions started in different directories contend on one lock), acquired before the cached-editor fetch so the stat-on-fetch sees a peer's completed write — keep that ordering, and resolve any geometry a mutation depends on (pin positions, routes) inside the guard, never from a pre-lock editor fetch; exports lock both the `.asc` and the sidecar `.net` they overwrite (fixed order `.asc`→`.net`); (2) job sidecars carry the owning server's `pid` — a running job with a live owner loads as `running` in other sessions, `services.resolve_job` and the job listings refresh it from disk via `refresh_foreign_job` (which swaps the registry entry only on the event loop; off-loop callers like resource-read worker threads get a read-only fresh view), and `cancel_running` only cancels `owner_pid == os.getpid()` jobs; (3) process kills are token-scoped (`lib/proc_kill.py` psutil + the WSL taskkill), matching simulator executable and job_id at a filename boundary in the command line — never reintroduce a name-global kill (spicelib's `kill_all_spice` is left unused on purpose: it's a no-op in the pinned spicelib and collateral once "fixed"). Known residuals: `max_parallel_sims` is enforced per runner instance, and runners are keyed by (kind, simulator, output folder) — so a process driving two simulators holds two caps (each named executable is a simulator class of its own, so two builds of one family are two simulators here), and N sessions multiply it again, same-file edit coherence on coarse-mtime filesystems (`/mnt/c` DrvFs) can still miss a same-size same-tick rewrite, and a pid recycled by an unrelated process reads as a live owner. **Artifacts are not scoped the way records are:** the experiment runs root is one directory per box (Windows temp under WSL), and every session's jobs land under it. Each job now gets its own `runs/{job_id}/` subdirectory, so its `.raw`/`.log`/staged decks are enumerable as a set instead of by guessing at a filename prefix — but the root is still shared and still readable by anything running as that user. An agent asked to recover a crashed session will inventory that folder with ordinary shell, find another session's job directories, and analyze them believing they are its own. An artifact's provenance comes from a job record that claims it, never from its presence in the folder.
### Result trust: report facts, do not rate them
Rules for any field that rates/flags/classifies a result (consumer is an LLM; rationale + canonical impl in `lib/result_observations.py`):
- **No trust verdicts** (`confidence`/`unreliable`/`suspect`/`degraded`): surface facts, let the model judge. Observation/`quality` codes name an input condition or provenance fact (window shape, which rail-samples were used, a value past a salience cutoff).
- **Severity is only relayed** — attach one only when the simulator already assigned it.
- **`status` ≠ trust:** lifecycle `status` (set early from raw size) stays separate from the diagnostics-derived trust signal.
- **Thresholds prefer** relay > topology > signature > relative > bare magnitude; magnitude stays advisory; emit a null/flag over a meaningless number; one constant, one meaning.
- **A batch reports completeness, not just status.** An aggregate over N requested runs must reconcile produced + failed against N and surface any shortfall as a fact — a terminal `completed` that silently omits dropped runs reports data loss as success, and the consumer has no way to know a run vanished. Count what was promised, not just what was collected: if the number that came back is less than the number asked for, that gap is itself a result to surface, never a rounding-down to "done".
- **`observations` vs `warnings` are two distinct channels — don't merge them.** `observations` = is the data/solve trustworthy (relayed log errors, non-finite/extreme values, coverage gaps; structured). `warnings` (on the analysis tools) = did this measurement make assumptions (clamped window, ambiguous edge, FFT-window approximation; free-text, actionable). A run-level solve failure is a data fact: read tools relay it into `observations` where they have that channel, else into their `warnings`. The metric tools lack an `observations` channel; add one (and move their solve-failure relay onto it) only when a second data-fact needs a home — never via a breaking `warnings`→`observations` migration. Full rationale in `lib/result_observations.py`.
### WSL Support
On WSL, LTspice.exe runs via Windows interop (not Wine). Key adaptations:
- `lib/ltspice_wsl.py`: `LTspiceWSL` subclass overrides `run()` to convert paths via `wslpath` instead of Wine's `Z:` prefix. Auto-selected by `lib/simulator.py` when `is_wsl()` is True.
- `simulator.path` in `ltspice-mcp.toml` must be set to the Windows-side path (e.g., `/mnt/c/Program Files/ADI/LTspice/LTspice.exe`) since spicelib can't auto-detect across the WSL boundary.
- The simulation **runner output folder is kept stable** (one `runs/` root per box, from `Store.runs_root`) so the cached runner, cancellation, and the global `max_parallel` cap stay valid — `RunnerManager` keys its cache on the output folder and a runner owns the launch permits that enforce `max_parallel_sims`, so a folder that varied per job would hand every job its own runner and its own full quota, and losing an in-flight runner loses its process handles. **Per-job grouping happens a level down**: `store.run_filename_in(job_id, name)` returns a relative sub-path that spicelib joins onto the unchanged folder (it copies the deck to `output_folder / run_filename` and derives the raw and log from that copy's own path), so a job's artifacts land in `runs/{job_id}/` without the runner moving. `ExperimentJob.output_folder` records that per-job directory, which is what the crash reconciliation and the kill-path cleanup both reconstruct from. **WSL + LTspice** moves the whole tree to a Windows-native temp dir, because LTspice (a Windows process reaching the Linux fs over a `wsl.localhost` UNC share) can't write the SQLite `.db` behind `.MEAS` over UNC — that decision lives in `Store.artifact_base` and every writer asks it. Cancellation resolves the runner by ownership, so its folder always matches the launching runner.
- LTspice requires netlist files to have an extension (`.cir`, `.net`, `.sp`). `runner_base.submit_netlist` preserves the original extension in `run_filename`.
### Configuration
`ltspice-mcp.toml` in working directory (written with defaults on the first tool call if missing, unless `LTSPICE_MCP_WRITE_CONFIG=false`). Environment variables with `LTSPICE_MCP_` prefix override TOML values. See `config.py:ServerConfig` for all options. On WSL, set `simulator.path` to the LTspice Windows executable path.
TOML sections: `[simulator]`, `[security]`, `[simulation]`, `[analysis]`, `[logging]`, `[schematic]`, `[tools]`, `[state]` (`persist_jobs`).
**Named executables** (`[simulator.executables]`, `LTSPICE_MCP_SIMULATOR_EXECUTABLES`): further builds of a family, selected per run as `execution.simulator = "ltspice:xvii"`. `lib/simulator.py:detect_named_simulators` binds each at startup to a subclass of the family's class (`bind_named_executable`) carrying its own `spice_exe`, kept on `state.named_simulators`; never call `create_from` on a family's own class, which retargets every runner already holding it. The subclass keeps the family's `__name__`, which is what records, the raw dialect and the linter key on, so anything new that identifies a build must read the executable (`simulator_build.executable_identity`), not the class name. Design and rationale: `docs/design/mcp_surface.md`, "Selecting a build".
**ngspice compatibility-mode gotcha:** spicelib runs ngspice in `kiltpsa` mode by default, whose `lt`/`ps` tokens make it read a sectioned `.lib <file> <section>` (the PDK corner-select idiom) as two plain includes and drop the section → "could not find include file". `[simulator] ngbehavior` (or `LTSPICE_MCP_NGBEHAVIOR`) overrides it (e.g. `"hsa"`), applied at startup by `lib/simulator.py:_apply_ngbehavior`. Runtime default unchanged (spicelib's). `run_experiments` refuses such a deck before it runs (lint `lib-section-ngspice`, keyed on the effective mode), and the finding's `reason` names the setting, since no simulator error follows a refused deck to tell the caller.
### Tool Profile
Since 0.6.0 the server has one profile, `consolidated` — 8 registered tools: six ops over three planes — EXECUTE (`run_experiments`, `jobs`), UNDERSTAND (`analyze_results`, `inspect`), AUTHOR (`edit_schematic`, `verify_circuit`) — all sharing one response envelope, plus `plot_waveform` (the interactive MCP Apps waveform widget, which predates the envelope and stays outside its contract), plus `run_code` (`tools/run_code.py` over the worker program `code_worker.py`: a Python snippet in a warm process holding the engine as `api`; registered always so its contract is gated with the rest, served unless `[tools] run_code = false`: its registration declares `gate="run_code"`, the `ServerConfig` field that must be true (it is, by default), and `get_tools(config=...)` derives the served surface from that one line; the field owners, the reference table, the capabilities entry and the instructions edition all read the served set, never the name). The worker is the session's to close (`SessionState.shutdown`), interrupts it with SIGINT on POSIX, and kills it after a grace; a raw write to the worker's fd 1 lands in the server log, never in the reply pipe. The six's handlers live in their own `tools/` modules (`experiments.py`, `jobs.py`, `analyze.py`, `schematic_edit.py`, `verify.py`, `inspect_tools.py`), with the receipt `run_experiments` and `jobs` share — its shape, schema, and renderers — in `receipts.py`. `tools/__init__.py`'s import list is the advertised tool order; do not alphabetise it. **The envelope, the per-tool argument shapes, and the rationale are in `docs/design/mcp_surface.md`** — read it before changing any of the six; this file does not duplicate it.
The former `full` (49-tool) and `agentic` (41-tool) profiles no longer exist, and `[tools] profile` is not a key the config loader reads — it is ignored like any other unknown key. Nothing keys on the profile anywhere: there is no `tool_profile` setting, the guide has one edition, the prompts have one edition, and registration, lookup and the resource routes take no profile parameter, because there is one surface to serve. `inspect(kind="capabilities")` still publishes `"tool_profile": "consolidated"` so a client can read which surface it is talking to. Tool defs and dispatch live on `SessionState` (`state.tool_defs`, `state.tool_dispatch`), built once at lifespan init from `tools.get_tools()`.
### Public Python API (`ltspice_mcp.api`)
The two interfaces are meant to run side by side: the MCP server is the
long-lived process that owns jobs which outlive a call, serves resources, and
renders the widget; the Python API is for loops and complete results in the
same working directory, reading and writing the same job records. A job the
API submits belongs to the process that submitted it, unless
`run_experiments(wait=False, detach=True)` hands it to a per-job owner
(`detached_owner.py`) spawned to supervise it — which is what lets a
short-lived script leave a job running. See docs/design/python_api.md §11 and
"Coexistence with a server".
The same six ops are importable: `Api(working_dir=...)` boots the engine in-process (`engine.bootstrap_library_engine` — the same bootstrap `server_lifespan` enters via `bootstrap_server_engine`) and exposes them as synchronous methods with **complete** results where the wire pages or caps, plus `load_raw`/`measurements` (numpy access, detached copies) and the AC/transient metric functions under their existing names. One evaluator, two interfaces (MCP and Python) — the handlers and the API consume the same evaluate/render seams, so anything that would fork semantics between them is a defect.
Constraints worth knowing before you use it: one live engine session per PID (an atomic lease shared with the server lifespan — an `Api` inside a server process raises); the `Api` owns a private persistent event loop (per-call loops would invalidate the runner cache); `close()` cancels jobs this process owns, exactly like server shutdown; default mode *rejects* wire-only controls (budget, cursors, dwell) instead of rewriting them so API/MCP replays stay idempotent (`raw_page=True` returns one MCP-identical page). **The full contract is `docs/design/python_api.md`**; `__all__` is the stability boundary and is pinned.