Decompose server.py: shared _app.mcp + spicebook_tools module

Introduce the multi-file FastMCP layout. The mcp instance moves to _app.py
(imports nothing from tool modules, keeping the registration graph acyclic),
and the self-contained SpiceBook domain (5 tools + status resource + publish
prompt + 2 helpers) moves to spicebook_tools.py. server.py imports it for the
registration side effect.

Tool/resource/prompt counts unchanged (42/6/8); names identical. Repointed
test_spicebook's _read_netlist import. server.py: 2472 -> 2168 lines.
This commit is contained in:
Ryan Malloy 2026-06-21 19:54:23 -06:00
parent 866aa46134
commit 3a7e6887aa
4 changed files with 321 additions and 309 deletions

51
src/mcltspice/_app.py Normal file
View File

@ -0,0 +1,51 @@
"""The shared FastMCP application instance.
Tools, resources, and prompts are defined across several modules (tools_*.py,
resources.py, prompts.py); each does `from ._app import mcp` and decorates with
this single instance. server.py imports those modules so their decorators run
and register everything onto this `mcp` before main() serves it.
This module imports nothing from the tool modules, which keeps the registration
graph acyclic.
"""
from fastmcp import FastMCP
mcp = FastMCP(
name="mcltspice",
instructions="""
LTspice MCP Server - Circuit simulation automation.
Use this server to:
- Run SPICE simulations on .asc schematics or .cir netlists
- Extract waveform data (voltages, currents) from simulation results
- Analyze signals: FFT, THD, RMS, bandwidth, settling time
- Create circuits from scratch using the netlist builder
- Create circuits from 10 pre-built templates (list_templates)
- Modify component values in schematics programmatically
- Browse LTspice's component library (6500+ symbols)
- Search 2800+ SPICE models and subcircuits
- Access example circuits (4000+ examples)
- Run design rule checks before simulation
- Compare schematics to see what changed
- Export waveform data to CSV
- Extract DC operating point (.op) and transfer function (.tf) data
- Analyze noise: spectral density, spot noise, RMS, noise figure, 1/f corner
- Measure stability (gain/phase margins from AC loop gain)
- Compute power and efficiency from voltage/current waveforms
- Evaluate waveform math expressions (V*I, gain, dB, etc.)
- Optimize component values to hit target specs automatically
- Generate .asc schematic files (graphical format)
- Run parameter sweeps, temperature sweeps, and Monte Carlo analysis
- Handle stepped simulations: list runs, extract per-run data
- Parse Touchstone (.s2p) S-parameter files
- Publish circuits as interactive notebooks on SpiceBook
LTspice runs via Wine on Linux. Simulations execute in batch mode
and results are parsed from binary .raw files.
SpiceBook integration lets you share simulation results as runnable
notebooks at spicebook.warehack.ing. Use spicebook_publish to convert
an LTspice netlist to ngspice format and create a shareable notebook.
""",
)

View File

@ -16,11 +16,13 @@ import math
import tempfile
from pathlib import Path
import httpx
import numpy as np
from fastmcp import FastMCP
from fastmcp.prompts import Message
# Domain modules register their tools/resources/prompts on the shared mcp
# instance at import time. Imported for that side effect only.
from . import spicebook_tools # noqa: F401,E402
from ._app import mcp
from .batch import (
run_monte_carlo,
run_parameter_sweep,
@ -29,7 +31,6 @@ from .batch import (
from .config import (
LTSPICE_EXAMPLES,
LTSPICE_LIB,
SPICEBOOK_URL,
validate_installation,
)
from .diff import diff_schematics as _diff_schematics
@ -55,11 +56,6 @@ from .power_analysis import compute_efficiency, compute_power_metrics
from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation
from .schematic import modify_component_value, parse_schematic
from .spicebook import (
SpiceBookClient,
build_notebook_cells,
ltspice_to_ngspice,
)
from .stability import compute_stability_metrics
from .templates import (
ASC_TEMPLATES,
@ -81,46 +77,6 @@ from .waveform_math import (
)
from .waveform_query import analyze_signal, extract_waveform
mcp = FastMCP(
name="mcltspice",
instructions="""
LTspice MCP Server - Circuit simulation automation.
Use this server to:
- Run SPICE simulations on .asc schematics or .cir netlists
- Extract waveform data (voltages, currents) from simulation results
- Analyze signals: FFT, THD, RMS, bandwidth, settling time
- Create circuits from scratch using the netlist builder
- Create circuits from 10 pre-built templates (list_templates)
- Modify component values in schematics programmatically
- Browse LTspice's component library (6500+ symbols)
- Search 2800+ SPICE models and subcircuits
- Access example circuits (4000+ examples)
- Run design rule checks before simulation
- Compare schematics to see what changed
- Export waveform data to CSV
- Extract DC operating point (.op) and transfer function (.tf) data
- Analyze noise: spectral density, spot noise, RMS, noise figure, 1/f corner
- Measure stability (gain/phase margins from AC loop gain)
- Compute power and efficiency from voltage/current waveforms
- Evaluate waveform math expressions (V*I, gain, dB, etc.)
- Optimize component values to hit target specs automatically
- Generate .asc schematic files (graphical format)
- Run parameter sweeps, temperature sweeps, and Monte Carlo analysis
- Handle stepped simulations: list runs, extract per-run data
- Parse Touchstone (.s2p) S-parameter files
- Publish circuits as interactive notebooks on SpiceBook
LTspice runs via Wine on Linux. Simulations execute in batch mode
and results are parsed from binary .raw files.
SpiceBook integration lets you share simulation results as runnable
notebooks at spicebook.warehack.ing. Use spicebook_publish to convert
an LTspice netlist to ngspice format and create a shareable notebook.
""",
)
# ============================================================================
# SIMULATION TOOLS
# ============================================================================
@ -2198,266 +2154,6 @@ Common failure modes:
""")]
# ============================================================================
# SPICEBOOK INTEGRATION TOOLS
# ============================================================================
_NETLIST_EXTENSIONS = {".cir", ".net", ".sp", ".spice", ".asc"}
_MAX_NETLIST_SIZE = 10 * 1024 * 1024 # 10 MB
def _spicebook_error(e: Exception) -> dict:
"""Format a SpiceBook error into a consistent response dict."""
if isinstance(e, httpx.ConnectError):
return {
"error": f"SpiceBook not reachable at {SPICEBOOK_URL}",
"hint": "Check connectivity or set SPICEBOOK_URL env var",
}
if isinstance(e, httpx.TimeoutException):
return {"error": "SpiceBook request timed out"}
if isinstance(e, httpx.HTTPStatusError):
body = e.response.text[:500] if e.response else ""
return {"error": f"HTTP {e.response.status_code}", "detail": body}
return {"error": str(e)}
def _read_netlist(
netlist_text: str | None, netlist_path: str | None
) -> tuple[str | None, dict | None]:
"""Resolve netlist from text or file path. Returns (text, error_dict)."""
if netlist_text and netlist_path:
return None, {"error": "Provide netlist_text or netlist_path, not both"}
if netlist_text:
if len(netlist_text) > _MAX_NETLIST_SIZE:
return None, {"error": f"Netlist text too large (max {_MAX_NETLIST_SIZE} bytes)"}
return netlist_text, None
if netlist_path:
p = Path(netlist_path).resolve()
if p.suffix.lower() not in _NETLIST_EXTENSIONS:
return None, {
"error": f"Unsupported file type '{p.suffix}'. "
f"Expected: {', '.join(sorted(_NETLIST_EXTENSIONS))}"
}
if not p.exists():
return None, {"error": f"File not found: {p}"}
try:
size = p.stat().st_size
if size > _MAX_NETLIST_SIZE:
return None, {"error": f"File too large ({size} bytes, max {_MAX_NETLIST_SIZE})"}
return p.read_text(encoding="utf-8"), None
except (OSError, UnicodeDecodeError) as e:
return None, {"error": f"Cannot read {p}: {e}"}
return None, {"error": "Provide netlist_text or netlist_path"}
@mcp.tool()
async def spicebook_publish(
netlist_text: str | None = None,
netlist_path: str | None = None,
title: str = "Untitled Circuit",
description: str | None = None,
tags: list[str] | None = None,
run: bool = False,
) -> dict:
"""Publish an LTspice netlist as an interactive SpiceBook notebook.
Converts the netlist from LTspice dialect to ngspice, builds
notebook cells, and creates the notebook on SpiceBook. Returns
the notebook ID and URL.
Args:
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
netlist_path: Path to a .cir/.net file on disk
title: Notebook title
description: Optional description shown in the intro cell
tags: Optional list of tags for categorization
run: If True, SpiceBook runs the simulation on publish
"""
text, err = _read_netlist(netlist_text, netlist_path)
if err:
return err
assert text is not None # _read_netlist guarantees text when err is None
converted, warnings = ltspice_to_ngspice(text)
cells = build_notebook_cells(
title=title,
description=description,
netlist=converted,
warnings=warnings if warnings else None,
)
client = SpiceBookClient()
try:
result = await client.compose(
title=title,
cells=cells,
tags=tags,
run=run,
)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
notebook_id = result.get("id", "")
return {
"notebook_id": notebook_id,
"url": f"{SPICEBOOK_URL}/notebooks/{notebook_id}",
"cell_count": len(cells),
"conversion_warnings": warnings,
}
@mcp.tool()
async def spicebook_list() -> dict:
"""List all notebooks on SpiceBook.
Returns notebook summaries including id, title, tags, and modified date.
"""
client = SpiceBookClient()
try:
notebooks = await client.list_notebooks()
except (httpx.HTTPError, OSError) as e:
return _spicebook_error(e)
return {"notebooks": notebooks, "count": len(notebooks)}
@mcp.tool()
async def spicebook_get(
notebook_id: str,
) -> dict:
"""Get a full SpiceBook notebook with all cells and outputs.
Args:
notebook_id: The notebook ID to retrieve
"""
client = SpiceBookClient()
try:
return await client.get_notebook(notebook_id)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
@mcp.tool()
async def spicebook_delete(
notebook_id: str,
) -> dict:
"""Delete a SpiceBook notebook.
Args:
notebook_id: The notebook ID to delete
"""
client = SpiceBookClient()
try:
await client.delete_notebook(notebook_id)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
return {"deleted": True, "notebook_id": notebook_id}
@mcp.tool()
async def spicebook_simulate(
netlist_text: str | None = None,
netlist_path: str | None = None,
) -> dict:
"""Run a netlist via SpiceBook's ngspice engine without creating a notebook.
Useful for testing ngspice compatibility before publishing.
Converts the netlist from LTspice dialect to ngspice first.
Args:
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
netlist_path: Path to a .cir/.net file on disk
"""
text, err = _read_netlist(netlist_text, netlist_path)
if err:
return err
assert text is not None
converted, warnings = ltspice_to_ngspice(text)
client = SpiceBookClient()
try:
result = await client.simulate(converted)
except (httpx.HTTPError, OSError) as e:
return _spicebook_error(e)
if warnings:
result["conversion_warnings"] = warnings
return result
# ============================================================================
# SPICEBOOK RESOURCE & PROMPT
# ============================================================================
@mcp.resource("spicebook://status")
async def spicebook_status() -> str:
"""SpiceBook service status -- URL and reachability."""
client = SpiceBookClient()
ok = await client.health()
status = "reachable" if ok else "unreachable"
return f"SpiceBook URL: {SPICEBOOK_URL}\nStatus: {status}"
@mcp.prompt()
def publish_to_spicebook(
circuit_description: str = "",
netlist_path: str = "",
) -> list:
"""Step-by-step guide for publishing a circuit to SpiceBook.
Walks through: verify locally in LTspice, test ngspice compatibility
via SpiceBook, then publish as an interactive notebook.
Args:
circuit_description: What circuit you want to publish
netlist_path: Path to the netlist or schematic
"""
path_note = (
f"Netlist/schematic: {netlist_path}"
if netlist_path
else "First, identify or create the netlist."
)
desc_note = (
f"Circuit: {circuit_description}"
if circuit_description
else "No description given -- describe the circuit for the notebook."
)
return [Message(role="user", content=f"""Publish a circuit to SpiceBook as an interactive notebook.
{path_note}
{desc_note}
Workflow:
1. **Verify locally** -- simulate the circuit with LTspice:
- Use simulate or simulate_netlist to confirm it works
- Use get_waveform / analyze_waveform to check results
- Fix any issues before publishing
2. **Test ngspice compatibility** -- dry run on SpiceBook:
- Use spicebook_simulate with the netlist
- Check for conversion warnings (LTspice -> ngspice differences)
- If errors occur, adjust the netlist (remove LTspice-specific constructs)
3. **Publish** -- create the notebook:
- Use spicebook_publish with a descriptive title and tags
- Include a description explaining the circuit purpose and key specs
- Set run=True if you want SpiceBook to execute the simulation
4. **Verify** -- check the published notebook:
- Use spicebook_get to confirm the notebook was created
- Visit the URL to see the interactive version
Tips:
- Add tags like "filter", "amplifier", "power" for discoverability
- The converter handles .backanno, Rser=, Windows paths automatically
- Use spicebook_list to see all published notebooks
- Use spicebook_delete to remove test notebooks
""")]
# ============================================================================
# ENTRY POINT
# ============================================================================

View File

@ -0,0 +1,265 @@
"""SpiceBook integration: publish/list/get/delete/simulate tools, plus a
status resource and a publishing prompt. Registers onto the shared mcp instance.
"""
from pathlib import Path
import httpx
from fastmcp.prompts import Message
from ._app import mcp
from .config import SPICEBOOK_URL
from .spicebook import (
SpiceBookClient,
build_notebook_cells,
ltspice_to_ngspice,
)
_NETLIST_EXTENSIONS = {".cir", ".net", ".sp", ".spice", ".asc"}
_MAX_NETLIST_SIZE = 10 * 1024 * 1024 # 10 MB
def _spicebook_error(e: Exception) -> dict:
"""Format a SpiceBook error into a consistent response dict."""
if isinstance(e, httpx.ConnectError):
return {
"error": f"SpiceBook not reachable at {SPICEBOOK_URL}",
"hint": "Check connectivity or set SPICEBOOK_URL env var",
}
if isinstance(e, httpx.TimeoutException):
return {"error": "SpiceBook request timed out"}
if isinstance(e, httpx.HTTPStatusError):
body = e.response.text[:500] if e.response else ""
return {"error": f"HTTP {e.response.status_code}", "detail": body}
return {"error": str(e)}
def _read_netlist(
netlist_text: str | None, netlist_path: str | None
) -> tuple[str | None, dict | None]:
"""Resolve netlist from text or file path. Returns (text, error_dict)."""
if netlist_text and netlist_path:
return None, {"error": "Provide netlist_text or netlist_path, not both"}
if netlist_text:
if len(netlist_text) > _MAX_NETLIST_SIZE:
return None, {"error": f"Netlist text too large (max {_MAX_NETLIST_SIZE} bytes)"}
return netlist_text, None
if netlist_path:
p = Path(netlist_path).resolve()
if p.suffix.lower() not in _NETLIST_EXTENSIONS:
return None, {
"error": f"Unsupported file type '{p.suffix}'. "
f"Expected: {', '.join(sorted(_NETLIST_EXTENSIONS))}"
}
if not p.exists():
return None, {"error": f"File not found: {p}"}
try:
size = p.stat().st_size
if size > _MAX_NETLIST_SIZE:
return None, {"error": f"File too large ({size} bytes, max {_MAX_NETLIST_SIZE})"}
return p.read_text(encoding="utf-8"), None
except (OSError, UnicodeDecodeError) as e:
return None, {"error": f"Cannot read {p}: {e}"}
return None, {"error": "Provide netlist_text or netlist_path"}
@mcp.tool()
async def spicebook_publish(
netlist_text: str | None = None,
netlist_path: str | None = None,
title: str = "Untitled Circuit",
description: str | None = None,
tags: list[str] | None = None,
run: bool = False,
) -> dict:
"""Publish an LTspice netlist as an interactive SpiceBook notebook.
Converts the netlist from LTspice dialect to ngspice, builds
notebook cells, and creates the notebook on SpiceBook. Returns
the notebook ID and URL.
Args:
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
netlist_path: Path to a .cir/.net file on disk
title: Notebook title
description: Optional description shown in the intro cell
tags: Optional list of tags for categorization
run: If True, SpiceBook runs the simulation on publish
"""
text, err = _read_netlist(netlist_text, netlist_path)
if err:
return err
assert text is not None # _read_netlist guarantees text when err is None
converted, warnings = ltspice_to_ngspice(text)
cells = build_notebook_cells(
title=title,
description=description,
netlist=converted,
warnings=warnings if warnings else None,
)
client = SpiceBookClient()
try:
result = await client.compose(
title=title,
cells=cells,
tags=tags,
run=run,
)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
notebook_id = result.get("id", "")
return {
"notebook_id": notebook_id,
"url": f"{SPICEBOOK_URL}/notebooks/{notebook_id}",
"cell_count": len(cells),
"conversion_warnings": warnings,
}
@mcp.tool()
async def spicebook_list() -> dict:
"""List all notebooks on SpiceBook.
Returns notebook summaries including id, title, tags, and modified date.
"""
client = SpiceBookClient()
try:
notebooks = await client.list_notebooks()
except (httpx.HTTPError, OSError) as e:
return _spicebook_error(e)
return {"notebooks": notebooks, "count": len(notebooks)}
@mcp.tool()
async def spicebook_get(
notebook_id: str,
) -> dict:
"""Get a full SpiceBook notebook with all cells and outputs.
Args:
notebook_id: The notebook ID to retrieve
"""
client = SpiceBookClient()
try:
return await client.get_notebook(notebook_id)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
@mcp.tool()
async def spicebook_delete(
notebook_id: str,
) -> dict:
"""Delete a SpiceBook notebook.
Args:
notebook_id: The notebook ID to delete
"""
client = SpiceBookClient()
try:
await client.delete_notebook(notebook_id)
except (httpx.HTTPError, OSError, ValueError) as e:
return _spicebook_error(e)
return {"deleted": True, "notebook_id": notebook_id}
@mcp.tool()
async def spicebook_simulate(
netlist_text: str | None = None,
netlist_path: str | None = None,
) -> dict:
"""Run a netlist via SpiceBook's ngspice engine without creating a notebook.
Useful for testing ngspice compatibility before publishing.
Converts the netlist from LTspice dialect to ngspice first.
Args:
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
netlist_path: Path to a .cir/.net file on disk
"""
text, err = _read_netlist(netlist_text, netlist_path)
if err:
return err
assert text is not None
converted, warnings = ltspice_to_ngspice(text)
client = SpiceBookClient()
try:
result = await client.simulate(converted)
except (httpx.HTTPError, OSError) as e:
return _spicebook_error(e)
if warnings:
result["conversion_warnings"] = warnings
return result
@mcp.resource("spicebook://status")
async def spicebook_status() -> str:
"""SpiceBook service status -- URL and reachability."""
client = SpiceBookClient()
ok = await client.health()
status = "reachable" if ok else "unreachable"
return f"SpiceBook URL: {SPICEBOOK_URL}\nStatus: {status}"
@mcp.prompt()
def publish_to_spicebook(
circuit_description: str = "",
netlist_path: str = "",
) -> list:
"""Step-by-step guide for publishing a circuit to SpiceBook.
Walks through: verify locally in LTspice, test ngspice compatibility
via SpiceBook, then publish as an interactive notebook.
Args:
circuit_description: What circuit you want to publish
netlist_path: Path to the netlist or schematic
"""
path_note = (
f"Netlist/schematic: {netlist_path}"
if netlist_path
else "First, identify or create the netlist."
)
desc_note = (
f"Circuit: {circuit_description}"
if circuit_description
else "No description given -- describe the circuit for the notebook."
)
return [Message(role="user", content=f"""Publish a circuit to SpiceBook as an interactive notebook.
{path_note}
{desc_note}
Workflow:
1. **Verify locally** -- simulate the circuit with LTspice:
- Use simulate or simulate_netlist to confirm it works
- Use get_waveform / analyze_waveform to check results
- Fix any issues before publishing
2. **Test ngspice compatibility** -- dry run on SpiceBook:
- Use spicebook_simulate with the netlist
- Check for conversion warnings (LTspice -> ngspice differences)
- If errors occur, adjust the netlist (remove LTspice-specific constructs)
3. **Publish** -- create the notebook:
- Use spicebook_publish with a descriptive title and tags
- Include a description explaining the circuit purpose and key specs
- Set run=True if you want SpiceBook to execute the simulation
4. **Verify** -- check the published notebook:
- Use spicebook_get to confirm the notebook was created
- Visit the URL to see the interactive version
Tips:
- Add tags like "filter", "amplifier", "power" for discoverability
- The converter handles .backanno, Rser=, Windows paths automatically
- Use spicebook_list to see all published notebooks
- Use spicebook_delete to remove test notebooks
""")]

View File

@ -2,12 +2,12 @@
import pytest
from mcltspice.server import _read_netlist
from mcltspice.spicebook import (
SpiceBookClient,
build_notebook_cells,
ltspice_to_ngspice,
)
from mcltspice.spicebook_tools import _read_netlist
# ---------------------------------------------------------------------------
# ltspice_to_ngspice converter tests