From 3a7e6887aac45762d9be1d8f29b0bc8326e7c139 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 21 Jun 2026 19:54:23 -0600 Subject: [PATCH] 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. --- src/mcltspice/_app.py | 51 +++++ src/mcltspice/server.py | 312 +------------------------------ src/mcltspice/spicebook_tools.py | 265 ++++++++++++++++++++++++++ tests/test_spicebook.py | 2 +- 4 files changed, 321 insertions(+), 309 deletions(-) create mode 100644 src/mcltspice/_app.py create mode 100644 src/mcltspice/spicebook_tools.py diff --git a/src/mcltspice/_app.py b/src/mcltspice/_app.py new file mode 100644 index 0000000..b1dba06 --- /dev/null +++ b/src/mcltspice/_app.py @@ -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. + """, +) diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py index 616142e..1181a66 100644 --- a/src/mcltspice/server.py +++ b/src/mcltspice/server.py @@ -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 # ============================================================================ diff --git a/src/mcltspice/spicebook_tools.py b/src/mcltspice/spicebook_tools.py new file mode 100644 index 0000000..9f5f94a --- /dev/null +++ b/src/mcltspice/spicebook_tools.py @@ -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 +""")] diff --git a/tests/test_spicebook.py b/tests/test_spicebook.py index 7d559c8..17b3e16 100644 --- a/tests/test_spicebook.py +++ b/tests/test_spicebook.py @@ -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