Initial mcp-ltspice: MCP server for LTspice circuit simulation

Wine batch-mode runner, binary .raw parser (UTF-16 LE + mixed
precision float64/float32), .asc schematic parser/editor, and
9 FastMCP tools for simulation automation on Linux.
This commit is contained in:
Ryan Malloy 2026-02-10 13:13:36 -07:00
commit 50953a4dea
10 changed files with 3742 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
.venv/
dist/
*.egg-info/
.ruff_cache/

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# mcp-ltspice
MCP server for LTspice circuit simulation automation on Linux.
## Features
- **Run Simulations** - Execute .asc schematics or .cir netlists in batch mode
- **Extract Waveforms** - Parse binary .raw files to get voltage/current data
- **Modify Schematics** - Programmatically edit component values
- **Browse Libraries** - Access 6500+ symbols and 4000+ example circuits
## Requirements
- **Linux** with Wine installed
- **LTspice** extracted (see below)
- **Python 3.11+**
## Installation
```bash
# From PyPI (once published)
uvx mcp-ltspice
# From source
uv pip install -e .
```
### LTspice Setup
Extract LTspice from the Windows MSI installer:
```bash
# Download LTspice64.msi from analog.com
cd /path/to/downloads
7z x LTspice64.msi -oltspice
cd ltspice
7z x disk1.cab
# Set up Wine prefix
export WINEPREFIX=$PWD/.wine
export WINEARCH=win64
wineboot --init
```
Set the `LTSPICE_DIR` environment variable or use the default `~/claude/ltspice/extracted/ltspice`.
## MCP Tools
| Tool | Description |
|------|-------------|
| `simulate` | Run simulation on .asc schematic |
| `simulate_netlist` | Run simulation on .cir netlist |
| `get_waveform` | Extract signal data from .raw file |
| `read_schematic` | Parse schematic components and nets |
| `edit_component` | Modify component values |
| `list_symbols` | Browse component symbol library |
| `list_examples` | Browse example circuits |
| `get_symbol_info` | Get symbol pins and attributes |
| `check_installation` | Verify LTspice setup |
## MCP Resources
- `ltspice://symbols` - All available component symbols
- `ltspice://examples` - All example circuits
- `ltspice://status` - Installation status
## Usage with Claude Code
```bash
claude mcp add mcp-ltspice -- uvx mcp-ltspice
```
Or for local development:
```bash
claude mcp add mcp-ltspice -- uv run --directory /path/to/mcp-ltspice mcp-ltspice
```
## License
MIT

55
pyproject.toml Normal file
View File

@ -0,0 +1,55 @@
[project]
name = "mcp-ltspice"
version = "2026.02.10"
description = "MCP server for LTspice circuit simulation automation"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
{name = "Ryan Malloy", email = "ryan@supported.systems"}
]
keywords = ["mcp", "ltspice", "spice", "circuit", "simulation", "electronics"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastmcp>=2.0.0",
"numpy>=1.24.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.1.0",
"pytest>=7.0.0",
]
plot = [
"matplotlib>=3.7.0",
]
[project.scripts]
mcp-ltspice = "mcp_ltspice.server:main"
[project.urls]
Repository = "https://github.com/ryanmalloy/mcp-ltspice"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_ltspice"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"]

View File

@ -0,0 +1,8 @@
"""MCP server for LTspice circuit simulation automation."""
from importlib.metadata import version
try:
__version__ = version("mcp-ltspice")
except Exception:
__version__ = "0.0.0"

42
src/mcp_ltspice/config.py Normal file
View File

@ -0,0 +1,42 @@
"""Configuration for mcp-ltspice."""
import os
from pathlib import Path
# LTspice installation paths
LTSPICE_DIR = Path(os.environ.get(
"LTSPICE_DIR",
Path.home() / "claude" / "ltspice" / "extracted" / "ltspice"
))
LTSPICE_EXE = LTSPICE_DIR / "LTspice.exe"
LTSPICE_LIB = LTSPICE_DIR / "lib"
LTSPICE_EXAMPLES = LTSPICE_DIR / "examples"
# Wine configuration
WINE_PREFIX = LTSPICE_DIR / ".wine"
WINE_DEBUG = os.environ.get("WINE_DEBUG", "-all")
# Simulation defaults
DEFAULT_TIMEOUT = 300 # 5 minutes
MAX_RAW_FILE_SIZE = 500 * 1024 * 1024 # 500MB
def get_wine_env() -> dict[str, str]:
"""Get environment variables for Wine execution."""
env = os.environ.copy()
env["WINEPREFIX"] = str(WINE_PREFIX)
env["WINEARCH"] = "win64"
env["WINEDEBUG"] = WINE_DEBUG
return env
def validate_installation() -> tuple[bool, str]:
"""Check if LTspice is properly installed."""
if not LTSPICE_DIR.exists():
return False, f"LTspice directory not found: {LTSPICE_DIR}"
if not LTSPICE_EXE.exists():
return False, f"LTspice executable not found: {LTSPICE_EXE}"
if not WINE_PREFIX.exists():
return False, f"Wine prefix not found: {WINE_PREFIX}"
return True, "LTspice installation OK"

View File

@ -0,0 +1,274 @@
"""Parser for LTspice .raw waveform files.
LTspice binary .raw format:
- Header: ASCII text with metadata (title, date, plotname, flags, variables, points)
- Data: Binary IEEE float64 (real) or float64 pairs (complex) depending on analysis type
"""
import struct
from dataclasses import dataclass
from pathlib import Path
import numpy as np
@dataclass
class Variable:
"""A variable (signal) in the raw file."""
index: int
name: str
type: str # e.g., "voltage", "current", "time", "frequency"
@dataclass
class RawFile:
"""Parsed LTspice .raw file."""
title: str
date: str
plotname: str
flags: list[str]
variables: list[Variable]
points: int
data: np.ndarray # Shape: (n_variables, n_points)
def get_variable(self, name: str) -> np.ndarray | None:
"""Get data for a variable by name (case-insensitive partial match)."""
name_lower = name.lower()
for var in self.variables:
if name_lower in var.name.lower():
return self.data[var.index]
return None
def get_time(self) -> np.ndarray | None:
"""Get the time axis (for transient analysis)."""
return self.get_variable("time")
def get_frequency(self) -> np.ndarray | None:
"""Get the frequency axis (for AC analysis)."""
return self.get_variable("frequency")
def parse_raw_file(path: Path | str) -> RawFile:
"""Parse an LTspice .raw file.
Args:
path: Path to the .raw file
Returns:
RawFile with parsed metadata and waveform data
Raises:
ValueError: If file format is not recognized
FileNotFoundError: If file doesn't exist
"""
path = Path(path)
with open(path, "rb") as f:
content = f.read()
# Detect encoding: UTF-16 LE (Windows) vs ASCII
# UTF-16 LE has null bytes between characters
is_utf16 = content[1:2] == b'\x00' and content[3:4] == b'\x00'
if is_utf16:
# UTF-16 LE encoding - decode header portion, find Binary marker
# "Binary:\n" in UTF-16 LE
binary_marker = "Binary:\n".encode("utf-16-le")
ascii_marker = "Values:\n".encode("utf-16-le")
else:
binary_marker = b"Binary:\n"
ascii_marker = b"Values:\n"
binary_offset = content.find(binary_marker)
ascii_offset = content.find(ascii_marker)
if binary_offset != -1:
header_end = binary_offset + len(binary_marker)
is_binary = True
elif ascii_offset != -1:
header_end = ascii_offset + len(ascii_marker)
is_binary = False
else:
raise ValueError("Could not find data section marker in .raw file")
# Parse header (handle encoding)
if is_utf16:
header = content[:header_end].decode("utf-16-le", errors="replace")
else:
header = content[:header_end].decode("utf-8", errors="replace")
header_lines = header.strip().split("\n")
title = ""
date = ""
plotname = ""
flags: list[str] = []
variables: list[Variable] = []
points = 0
in_variables = False
var_count = 0
for line in header_lines:
line = line.strip()
if line.startswith("Title:"):
title = line[6:].strip()
elif line.startswith("Date:"):
date = line[5:].strip()
elif line.startswith("Plotname:"):
plotname = line[9:].strip()
elif line.startswith("Flags:"):
flags = line[6:].strip().split()
elif line.startswith("No. Variables:"):
var_count = int(line[14:].strip())
elif line.startswith("No. Points:"):
points = int(line[11:].strip())
elif line.startswith("Variables:"):
in_variables = True
elif in_variables and line and not line.startswith("Binary") and not line.startswith("Values"):
# Parse variable line: "index name type"
parts = line.split()
if len(parts) >= 3:
idx = int(parts[0])
name = parts[1]
vtype = parts[2]
variables.append(Variable(idx, name, vtype))
if not variables:
raise ValueError("No variables found in .raw file header")
# Parse data section
data_bytes = content[header_end:]
n_vars = len(variables)
is_complex = "complex" in flags
is_stepped = "stepped" in flags
is_forward = "forward" in flags # FastAccess format
if is_binary:
data = _parse_binary_data(data_bytes, n_vars, points, is_complex)
else:
data = _parse_ascii_data(data_bytes.decode("utf-8"), n_vars, points, is_complex)
return RawFile(
title=title,
date=date,
plotname=plotname,
flags=flags,
variables=variables,
points=points,
data=data,
)
def _parse_binary_data(
data: bytes, n_vars: int, points: int, is_complex: bool
) -> np.ndarray:
"""Parse binary data section.
LTspice binary formats:
- Real transient: time (float64) + other vars (float32)
- Real all-double: all variables as float64 (older format)
- Complex AC: frequency (float64) + other vars as (float64, float64) pairs
"""
if is_complex:
# Complex data (AC analysis): freq as double + complex values
# freq (8 bytes) + (n_vars-1) * (real + imag) = 8 + (n-1)*16 bytes per point
bytes_per_point = 8 + (n_vars - 1) * 16
expected_bytes = bytes_per_point * points
if len(data) >= expected_bytes:
result = np.zeros((n_vars, points), dtype=np.complex128)
offset = 0
for p in range(points):
# Frequency as double (real)
result[0, p] = struct.unpack("<d", data[offset:offset + 8])[0]
offset += 8
# Rest are complex (real, imag) pairs
for v in range(1, n_vars):
real = struct.unpack("<d", data[offset:offset + 8])[0]
imag = struct.unpack("<d", data[offset + 8:offset + 16])[0]
result[v, p] = complex(real, imag)
offset += 16
return result
# Real data - detect format from data size
# Mixed format: time (8 bytes) + other vars (4 bytes each)
bytes_per_point_mixed = 8 + (n_vars - 1) * 4
bytes_per_point_double = n_vars * 8
expected_mixed = bytes_per_point_mixed * points
expected_double = bytes_per_point_double * points
if abs(len(data) - expected_mixed) < abs(len(data) - expected_double):
# Mixed precision format (common in newer LTspice)
actual_points = len(data) // bytes_per_point_mixed
result = np.zeros((n_vars, actual_points), dtype=np.float64)
offset = 0
for p in range(actual_points):
# Time as double
result[0, p] = struct.unpack("<d", data[offset:offset + 8])[0]
offset += 8
# Other variables as float32
for v in range(1, n_vars):
result[v, p] = struct.unpack("<f", data[offset:offset + 4])[0]
offset += 4
return result
else:
# All double format (older LTspice or specific settings)
actual_points = len(data) // bytes_per_point_double
flat = np.frombuffer(data[:actual_points * bytes_per_point_double], dtype=np.float64)
# LTspice stores point-by-point
return flat.reshape(actual_points, n_vars).T.copy()
def _parse_ascii_data(
data: str, n_vars: int, points: int, is_complex: bool
) -> np.ndarray:
"""Parse ASCII data section."""
lines = data.strip().split("\n")
if is_complex:
result = np.zeros((n_vars, points), dtype=np.complex128)
else:
result = np.zeros((n_vars, points), dtype=np.float64)
point_idx = 0
var_idx = 0
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 2:
# Format: "var_idx value" or "var_idx real,imag"
try:
idx = int(parts[0])
if idx == 0:
if var_idx > 0:
point_idx += 1
var_idx = 0
if point_idx < points:
value_str = parts[1]
if "," in value_str:
# Complex value
real, imag = value_str.split(",")
result[var_idx, point_idx] = complex(float(real), float(imag))
else:
result[var_idx, point_idx] = float(value_str)
var_idx += 1
except (ValueError, IndexError):
continue
return result

332
src/mcp_ltspice/runner.py Normal file
View File

@ -0,0 +1,332 @@
"""Run LTspice simulations via Wine batch mode."""
import asyncio
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from .config import (
DEFAULT_TIMEOUT,
LTSPICE_EXE,
MAX_RAW_FILE_SIZE,
get_wine_env,
validate_installation,
)
from .raw_parser import RawFile, parse_raw_file
@dataclass
class SimulationResult:
"""Result of a simulation run."""
success: bool
raw_file: Path | None
log_file: Path | None
raw_data: RawFile | None
error: str | None
stdout: str
stderr: str
elapsed_seconds: float
async def run_simulation(
schematic_path: Path | str,
timeout: float = DEFAULT_TIMEOUT,
work_dir: Path | str | None = None,
parse_results: bool = True,
) -> SimulationResult:
"""Run an LTspice simulation in batch mode.
Args:
schematic_path: Path to .asc schematic file
timeout: Maximum simulation time in seconds
work_dir: Working directory for output files (temp dir if None)
parse_results: Whether to parse the .raw file
Returns:
SimulationResult with status and data
"""
import time
start_time = time.monotonic()
# Validate installation
ok, msg = validate_installation()
if not ok:
return SimulationResult(
success=False,
raw_file=None,
log_file=None,
raw_data=None,
error=msg,
stdout="",
stderr="",
elapsed_seconds=0,
)
schematic_path = Path(schematic_path).resolve()
if not schematic_path.exists():
return SimulationResult(
success=False,
raw_file=None,
log_file=None,
raw_data=None,
error=f"Schematic not found: {schematic_path}",
stdout="",
stderr="",
elapsed_seconds=0,
)
# Set up working directory
temp_dir = None
if work_dir:
work_dir = Path(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
else:
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
work_dir = Path(temp_dir)
try:
# Copy schematic to work directory
work_schematic = work_dir / schematic_path.name
shutil.copy2(schematic_path, work_schematic)
# Copy any .lib, .sub files from the same directory
src_dir = schematic_path.parent
for ext in [".lib", ".sub", ".inc", ".model"]:
for f in src_dir.glob(f"*{ext}"):
shutil.copy2(f, work_dir / f.name)
# Convert path to Windows format for Wine
# Wine maps Z: to root filesystem
win_path = "Z:" + str(work_schematic).replace("/", "\\")
# Build command
cmd = [
"wine",
str(LTSPICE_EXE),
"-b", # Batch mode
win_path,
]
# Run simulation
env = get_wine_env()
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=str(work_dir),
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return SimulationResult(
success=False,
raw_file=None,
log_file=None,
raw_data=None,
error=f"Simulation timed out after {timeout} seconds",
stdout="",
stderr="",
elapsed_seconds=time.monotonic() - start_time,
)
stdout = stdout_bytes.decode("utf-8", errors="replace")
stderr = stderr_bytes.decode("utf-8", errors="replace")
elapsed = time.monotonic() - start_time
# Look for output files
raw_file = work_schematic.with_suffix(".raw")
log_file = work_schematic.with_suffix(".log")
if not raw_file.exists():
# Check for error in log
error_msg = "Simulation failed - no .raw file produced"
if log_file.exists():
log_content = log_file.read_text(errors="replace")
if "Error" in log_content or "error" in log_content:
error_msg = f"Simulation error: {log_content[:500]}"
return SimulationResult(
success=False,
raw_file=None,
log_file=log_file if log_file.exists() else None,
raw_data=None,
error=error_msg,
stdout=stdout,
stderr=stderr,
elapsed_seconds=elapsed,
)
# Check file size
if raw_file.stat().st_size > MAX_RAW_FILE_SIZE:
return SimulationResult(
success=True,
raw_file=raw_file,
log_file=log_file if log_file.exists() else None,
raw_data=None,
error=f"Raw file too large to parse ({raw_file.stat().st_size} bytes)",
stdout=stdout,
stderr=stderr,
elapsed_seconds=elapsed,
)
# Parse results
raw_data = None
parse_error = None
if parse_results:
try:
raw_data = parse_raw_file(raw_file)
except Exception as e:
parse_error = f"Failed to parse .raw file: {e}"
return SimulationResult(
success=True,
raw_file=raw_file,
log_file=log_file if log_file.exists() else None,
raw_data=raw_data,
error=parse_error,
stdout=stdout,
stderr=stderr,
elapsed_seconds=elapsed,
)
finally:
# Clean up temp directory (but not user-specified work_dir)
if temp_dir:
# Keep files for debugging if simulation failed
pass # Let user specify cleanup behavior
async def run_netlist(
netlist_path: Path | str,
timeout: float = DEFAULT_TIMEOUT,
work_dir: Path | str | None = None,
parse_results: bool = True,
) -> SimulationResult:
"""Run an LTspice simulation from a netlist (.cir/.net) file.
This is similar to run_simulation but for netlist files.
Args:
netlist_path: Path to .cir or .net netlist file
timeout: Maximum simulation time in seconds
work_dir: Working directory for output files
parse_results: Whether to parse the .raw file
Returns:
SimulationResult with status and data
"""
import time
start_time = time.monotonic()
ok, msg = validate_installation()
if not ok:
return SimulationResult(
success=False, raw_file=None, log_file=None, raw_data=None,
error=msg, stdout="", stderr="", elapsed_seconds=0,
)
netlist_path = Path(netlist_path).resolve()
if not netlist_path.exists():
return SimulationResult(
success=False, raw_file=None, log_file=None, raw_data=None,
error=f"Netlist not found: {netlist_path}",
stdout="", stderr="", elapsed_seconds=0,
)
temp_dir = None
if work_dir:
work_dir = Path(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
else:
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
work_dir = Path(temp_dir)
try:
# Copy netlist to work directory
work_netlist = work_dir / netlist_path.name
shutil.copy2(netlist_path, work_netlist)
# Copy included files
src_dir = netlist_path.parent
for ext in [".lib", ".sub", ".inc", ".model"]:
for f in src_dir.glob(f"*{ext}"):
shutil.copy2(f, work_dir / f.name)
win_path = "Z:" + str(work_netlist).replace("/", "\\")
cmd = ["wine", str(LTSPICE_EXE), "-b", win_path]
env = get_wine_env()
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=str(work_dir),
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return SimulationResult(
success=False, raw_file=None, log_file=None, raw_data=None,
error=f"Simulation timed out after {timeout} seconds",
stdout="", stderr="",
elapsed_seconds=time.monotonic() - start_time,
)
stdout = stdout_bytes.decode("utf-8", errors="replace")
stderr = stderr_bytes.decode("utf-8", errors="replace")
elapsed = time.monotonic() - start_time
raw_file = work_netlist.with_suffix(".raw")
log_file = work_netlist.with_suffix(".log")
if not raw_file.exists():
error_msg = "Simulation failed - no .raw file produced"
if log_file.exists():
log_content = log_file.read_text(errors="replace")
if "error" in log_content.lower():
error_msg = f"Simulation error: {log_content[:500]}"
return SimulationResult(
success=False,
raw_file=None,
log_file=log_file if log_file.exists() else None,
raw_data=None, error=error_msg,
stdout=stdout, stderr=stderr, elapsed_seconds=elapsed,
)
raw_data = None
parse_error = None
if parse_results and raw_file.stat().st_size <= MAX_RAW_FILE_SIZE:
try:
raw_data = parse_raw_file(raw_file)
except Exception as e:
parse_error = f"Failed to parse .raw file: {e}"
return SimulationResult(
success=True,
raw_file=raw_file,
log_file=log_file if log_file.exists() else None,
raw_data=raw_data,
error=parse_error,
stdout=stdout, stderr=stderr, elapsed_seconds=elapsed,
)
finally:
pass # Keep temp files for debugging

View File

@ -0,0 +1,286 @@
"""Parser and editor for LTspice .asc schematic files.
LTspice .asc format is a simple text format with components, wires, and directives.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Component:
"""A component instance in the schematic."""
name: str # Instance name (e.g., R1, C1, M1)
symbol: str # Symbol name (e.g., res, cap, nmos)
x: int
y: int
rotation: int # 0, 90, 180, 270
mirror: bool
attributes: dict[str, str] = field(default_factory=dict)
@property
def value(self) -> str | None:
"""Get component value (e.g., '10k' for resistor)."""
return self.attributes.get("Value") or self.attributes.get("Value2")
@value.setter
def value(self, val: str):
"""Set component value."""
self.attributes["Value"] = val
@dataclass
class Wire:
"""A wire connection."""
x1: int
y1: int
x2: int
y2: int
@dataclass
class Text:
"""Text annotation or SPICE directive."""
x: int
y: int
content: str
type: str = "comment" # "comment" or "spice"
@dataclass
class Flag:
"""A net flag/label."""
x: int
y: int
name: str
type: str = "0" # Net type
@dataclass
class Schematic:
"""A parsed LTspice schematic."""
version: int = 4
sheet: tuple[int, int, int, int] = (1, 1, 0, 0)
components: list[Component] = field(default_factory=list)
wires: list[Wire] = field(default_factory=list)
texts: list[Text] = field(default_factory=list)
flags: list[Flag] = field(default_factory=list)
def get_component(self, name: str) -> Component | None:
"""Get component by instance name (case-insensitive)."""
name_lower = name.lower()
for comp in self.components:
if comp.name.lower() == name_lower:
return comp
return None
def get_components_by_symbol(self, symbol: str) -> list[Component]:
"""Get all components using a specific symbol."""
symbol_lower = symbol.lower()
return [c for c in self.components if c.symbol.lower() == symbol_lower]
def get_spice_directives(self) -> list[str]:
"""Get all SPICE directives from the schematic."""
return [t.content for t in self.texts if t.type == "spice"]
def parse_schematic(path: Path | str) -> Schematic:
"""Parse an LTspice .asc schematic file.
Args:
path: Path to the .asc file
Returns:
Schematic object with parsed contents
"""
path = Path(path)
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.split("\n")
schematic = Schematic()
current_component: Component | None = None
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("Version"):
parts = line.split()
if len(parts) >= 2:
schematic.version = int(parts[1])
elif line.startswith("SHEET"):
parts = line.split()
if len(parts) >= 5:
schematic.sheet = (
int(parts[1]), int(parts[2]),
int(parts[3]), int(parts[4])
)
elif line.startswith("WIRE"):
parts = line.split()
if len(parts) >= 5:
schematic.wires.append(Wire(
int(parts[1]), int(parts[2]),
int(parts[3]), int(parts[4])
))
elif line.startswith("FLAG"):
parts = line.split()
if len(parts) >= 4:
schematic.flags.append(Flag(
int(parts[1]), int(parts[2]),
parts[3],
parts[4] if len(parts) > 4 else "0"
))
elif line.startswith("SYMBOL"):
# Save previous component before starting a new one
if current_component and current_component.name:
schematic.components.append(current_component)
# Start of a new component
parts = line.split()
if len(parts) >= 4:
symbol = parts[1]
x = int(parts[2])
y = int(parts[3])
# Parse rotation/mirror from remaining parts
rotation = 0
mirror = False
if len(parts) > 4:
rot_str = parts[4]
if rot_str.startswith("M"):
mirror = True
rot_str = rot_str[1:]
if rot_str.startswith("R"):
rotation = int(rot_str[1:])
current_component = Component(
name="",
symbol=symbol,
x=x, y=y,
rotation=rotation,
mirror=mirror
)
else:
current_component = None
elif line.startswith("SYMATTR") and current_component:
parts = line.split(None, 2)
if len(parts) >= 3:
attr_name = parts[1]
attr_value = parts[2]
if attr_name == "InstName":
current_component.name = attr_value
else:
current_component.attributes[attr_name] = attr_value
elif line.startswith("TEXT"):
# Parse text/directive
match = re.match(r"TEXT\s+(-?\d+)\s+(-?\d+)\s+(\w+)\s+(\d+)\s*(.*)", line)
if match:
x, y = int(match.group(1)), int(match.group(2))
align = match.group(3)
size = int(match.group(4))
content = match.group(5) if match.group(5) else ""
# Check for multi-line text (continuation with \n or actual newlines)
text_type = "comment"
if content.startswith("!"):
text_type = "spice"
content = content[1:]
elif content.startswith(";"):
content = content[1:]
schematic.texts.append(Text(x, y, content, text_type))
i += 1
# Save the last component if not already saved
if current_component and current_component.name:
if current_component not in schematic.components:
schematic.components.append(current_component)
return schematic
def write_schematic(schematic: Schematic, path: Path | str) -> None:
"""Write a schematic to an .asc file.
Args:
schematic: Schematic to write
path: Output path
"""
path = Path(path)
lines = []
lines.append(f"Version {schematic.version}")
lines.append(f"SHEET {schematic.sheet[0]} {schematic.sheet[1]} {schematic.sheet[2]} {schematic.sheet[3]}")
# Write wires
for wire in schematic.wires:
lines.append(f"WIRE {wire.x1} {wire.y1} {wire.x2} {wire.y2}")
# Write flags
for flag in schematic.flags:
lines.append(f"FLAG {flag.x} {flag.y} {flag.name}")
# Write components
for comp in schematic.components:
rot_str = f"R{comp.rotation}"
if comp.mirror:
rot_str = "M" + rot_str
lines.append(f"SYMBOL {comp.symbol} {comp.x} {comp.y} {rot_str}")
lines.append(f"SYMATTR InstName {comp.name}")
for attr_name, attr_value in comp.attributes.items():
if attr_name != "InstName":
lines.append(f"SYMATTR {attr_name} {attr_value}")
# Write text/directives
for text in schematic.texts:
prefix = "!" if text.type == "spice" else ""
lines.append(f"TEXT {text.x} {text.y} Left 2 {prefix}{text.content}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def modify_component_value(
path: Path | str,
component_name: str,
new_value: str,
output_path: Path | str | None = None
) -> Schematic:
"""Modify a component's value in a schematic.
Args:
path: Input schematic path
component_name: Name of component to modify (e.g., "R1")
new_value: New value to set
output_path: Output path (defaults to overwriting input)
Returns:
Modified schematic
Raises:
ValueError: If component not found
"""
schematic = parse_schematic(path)
comp = schematic.get_component(component_name)
if not comp:
available = [c.name for c in schematic.components]
raise ValueError(
f"Component '{component_name}' not found. "
f"Available components: {', '.join(available)}"
)
comp.value = new_value
write_schematic(schematic, output_path or path)
return schematic

531
src/mcp_ltspice/server.py Normal file
View File

@ -0,0 +1,531 @@
"""FastMCP server for LTspice circuit simulation automation.
This server provides tools for:
- Running SPICE simulations
- Extracting waveform data
- Modifying schematic components
- Browsing component libraries and examples
"""
import json
from pathlib import Path
from fastmcp import FastMCP
from . import __version__
from .config import (
LTSPICE_EXAMPLES,
LTSPICE_LIB,
validate_installation,
)
from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation
from .schematic import (
modify_component_value,
parse_schematic,
)
# Initialize FastMCP server
mcp = FastMCP(
name="mcp-ltspice",
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
- Modify component values in schematics programmatically
- Browse LTspice's component library (6500+ symbols)
- Access example circuits (4000+ examples)
LTspice runs via Wine on Linux. Simulations execute in batch mode
and results are parsed from binary .raw files.
""",
)
# ============================================================================
# TOOLS
# ============================================================================
@mcp.tool()
async def simulate(
schematic_path: str,
timeout_seconds: float = 300,
) -> dict:
"""Run an LTspice simulation on a schematic file.
This runs LTspice in batch mode, which executes any simulation
directives (.tran, .ac, .dc, .op, etc.) in the schematic.
Args:
schematic_path: Absolute path to .asc schematic file
timeout_seconds: Maximum time to wait for simulation (default 5 min)
Returns:
dict with:
- success: bool
- elapsed_seconds: simulation time
- variables: list of signal names available
- points: number of data points
- error: error message if failed
"""
result = await run_simulation(
schematic_path,
timeout=timeout_seconds,
parse_results=True,
)
response = {
"success": result.success,
"elapsed_seconds": result.elapsed_seconds,
"error": result.error,
}
if result.raw_data:
response["variables"] = [
{"name": v.name, "type": v.type}
for v in result.raw_data.variables
]
response["points"] = result.raw_data.points
response["plotname"] = result.raw_data.plotname
response["raw_file"] = str(result.raw_file) if result.raw_file else None
return response
@mcp.tool()
async def simulate_netlist(
netlist_path: str,
timeout_seconds: float = 300,
) -> dict:
"""Run an LTspice simulation on a netlist file.
Use this for .cir or .net SPICE netlist files instead of
schematic .asc files.
Args:
netlist_path: Absolute path to .cir or .net netlist file
timeout_seconds: Maximum time to wait for simulation
Returns:
dict with simulation results (same as simulate)
"""
result = await run_netlist(
netlist_path,
timeout=timeout_seconds,
parse_results=True,
)
response = {
"success": result.success,
"elapsed_seconds": result.elapsed_seconds,
"error": result.error,
}
if result.raw_data:
response["variables"] = [
{"name": v.name, "type": v.type}
for v in result.raw_data.variables
]
response["points"] = result.raw_data.points
response["raw_file"] = str(result.raw_file) if result.raw_file else None
return response
@mcp.tool()
def get_waveform(
raw_file_path: str,
signal_names: list[str],
max_points: int = 1000,
) -> dict:
"""Extract waveform data from a .raw simulation results file.
After running a simulation, use this to get the actual data values.
For transient analysis, includes time axis. For AC, includes frequency.
Args:
raw_file_path: Path to .raw file from simulation
signal_names: List of signal names to extract (partial match OK)
e.g., ["V(out)", "I(R1)"] or just ["out", "R1"]
max_points: Maximum data points to return (downsampled if needed)
Returns:
dict with:
- time_or_frequency: the x-axis data
- signals: dict mapping signal name to data array
- units: dict mapping signal name to unit type
"""
raw = parse_raw_file(raw_file_path)
# Get x-axis (time or frequency)
x_axis = raw.get_time()
x_name = "time"
if x_axis is None:
x_axis = raw.get_frequency()
x_name = "frequency"
# Downsample if needed
total_points = len(x_axis) if x_axis is not None else raw.points
step = max(1, total_points // max_points)
result = {
"x_axis_name": x_name,
"x_axis_data": [],
"signals": {},
"total_points": total_points,
"returned_points": 0,
}
if x_axis is not None:
sampled = x_axis[::step]
# Convert complex to magnitude for frequency domain
if sampled.dtype == complex:
result["x_axis_data"] = [abs(x) for x in sampled]
else:
result["x_axis_data"] = sampled.tolist()
result["returned_points"] = len(result["x_axis_data"])
# Extract requested signals
for name in signal_names:
data = raw.get_variable(name)
if data is not None:
sampled = data[::step]
# Handle complex data (AC analysis)
if sampled.dtype == complex:
result["signals"][name] = {
"magnitude": [abs(x) for x in sampled],
"phase_degrees": [
(180 / 3.14159) * (x.imag / x.real if x.real != 0 else 0)
for x in sampled
],
"real": [x.real for x in sampled],
"imag": [x.imag for x in sampled],
}
else:
result["signals"][name] = {"values": sampled.tolist()}
return result
@mcp.tool()
def read_schematic(schematic_path: str) -> dict:
"""Read and parse an LTspice schematic file.
Returns component list, net names, and SPICE directives.
Args:
schematic_path: Path to .asc schematic file
Returns:
dict with:
- components: list of {name, symbol, value, x, y}
- nets: list of net/flag names
- directives: list of SPICE directive strings
"""
sch = parse_schematic(schematic_path)
return {
"version": sch.version,
"components": [
{
"name": c.name,
"symbol": c.symbol,
"value": c.value,
"x": c.x,
"y": c.y,
"attributes": c.attributes,
}
for c in sch.components
],
"nets": [f.name for f in sch.flags],
"directives": sch.get_spice_directives(),
"wire_count": len(sch.wires),
}
@mcp.tool()
def edit_component(
schematic_path: str,
component_name: str,
new_value: str,
output_path: str | None = None,
) -> dict:
"""Modify a component's value in a schematic.
Use this to change resistor values, capacitor values, etc.
programmatically before running a simulation.
Args:
schematic_path: Path to .asc schematic file
component_name: Instance name like "R1", "C2", "M1"
new_value: New value string, e.g., "10k", "100n", "2N7000"
output_path: Where to save modified schematic (None = overwrite)
Returns:
dict with success status and component details
"""
try:
sch = modify_component_value(
schematic_path,
component_name,
new_value,
output_path,
)
comp = sch.get_component(component_name)
return {
"success": True,
"component": component_name,
"new_value": new_value,
"output_path": output_path or schematic_path,
"symbol": comp.symbol if comp else None,
}
except ValueError as e:
return {
"success": False,
"error": str(e),
}
@mcp.tool()
def list_symbols(
category: str | None = None,
search: str | None = None,
limit: int = 50,
) -> dict:
"""List available component symbols from LTspice library.
Symbols define the graphical representation and pins of components.
Args:
category: Filter by category folder (e.g., "Opamps", "Comparators")
search: Search term for symbol name (case-insensitive)
limit: Maximum results to return
Returns:
dict with:
- symbols: list of {name, category, path}
- total_count: total matching symbols
"""
symbols = []
sym_dir = LTSPICE_LIB / "sym"
if not sym_dir.exists():
return {"error": "Symbol library not found", "symbols": [], "total_count": 0}
for asy_file in sym_dir.rglob("*.asy"):
rel_path = asy_file.relative_to(sym_dir)
cat = str(rel_path.parent) if rel_path.parent != Path(".") else "misc"
name = asy_file.stem
# Apply filters
if category and cat.lower() != category.lower():
continue
if search and search.lower() not in name.lower():
continue
symbols.append({
"name": name,
"category": cat,
"path": str(asy_file),
})
# Sort by name
symbols.sort(key=lambda x: x["name"].lower())
total = len(symbols)
return {
"symbols": symbols[:limit],
"total_count": total,
"returned_count": min(limit, total),
}
@mcp.tool()
def list_examples(
category: str | None = None,
search: str | None = None,
limit: int = 50,
) -> dict:
"""List example circuits from LTspice examples library.
Great for learning or as starting points for new designs.
Args:
category: Filter by category folder
search: Search term for example name
limit: Maximum results to return
Returns:
dict with list of example schematics
"""
examples = []
if not LTSPICE_EXAMPLES.exists():
return {"error": "Examples not found", "examples": [], "total_count": 0}
for asc_file in LTSPICE_EXAMPLES.rglob("*.asc"):
rel_path = asc_file.relative_to(LTSPICE_EXAMPLES)
cat = str(rel_path.parent) if rel_path.parent != Path(".") else "misc"
name = asc_file.stem
if category and cat.lower() != category.lower():
continue
if search and search.lower() not in name.lower():
continue
examples.append({
"name": name,
"category": cat,
"path": str(asc_file),
})
examples.sort(key=lambda x: x["name"].lower())
total = len(examples)
return {
"examples": examples[:limit],
"total_count": total,
"returned_count": min(limit, total),
}
@mcp.tool()
def get_symbol_info(symbol_path: str) -> dict:
"""Get detailed information about a component symbol.
Reads the .asy file to extract pin names, attributes, and description.
Args:
symbol_path: Path to .asy symbol file
Returns:
dict with symbol details including pins and default attributes
"""
path = Path(symbol_path)
if not path.exists():
return {"error": f"Symbol not found: {symbol_path}"}
content = path.read_text(errors="replace")
lines = content.split("\n")
info = {
"name": path.stem,
"pins": [],
"attributes": {},
"description": "",
"prefix": "",
"spice_prefix": "",
}
for line in lines:
line = line.strip()
if line.startswith("PIN"):
parts = line.split()
if len(parts) >= 5:
info["pins"].append({
"x": int(parts[1]),
"y": int(parts[2]),
"justification": parts[3],
"rotation": parts[4] if len(parts) > 4 else "0",
})
elif line.startswith("PINATTR PinName"):
pin_name = line.split(None, 2)[2] if len(line.split()) > 2 else ""
if info["pins"]:
info["pins"][-1]["name"] = pin_name
elif line.startswith("SYMATTR"):
parts = line.split(None, 2)
if len(parts) >= 3:
attr_name = parts[1]
attr_value = parts[2]
info["attributes"][attr_name] = attr_value
if attr_name == "Description":
info["description"] = attr_value
elif attr_name == "Prefix":
info["prefix"] = attr_value
elif attr_name == "SpiceModel":
info["spice_prefix"] = attr_value
return info
@mcp.tool()
def check_installation() -> dict:
"""Verify LTspice and Wine are properly installed.
Returns:
dict with installation status and paths
"""
ok, msg = validate_installation()
from .config import LTSPICE_DIR, LTSPICE_EXE, WINE_PREFIX
return {
"valid": ok,
"message": msg,
"paths": {
"ltspice_dir": str(LTSPICE_DIR),
"ltspice_exe": str(LTSPICE_EXE),
"wine_prefix": str(WINE_PREFIX),
"lib_dir": str(LTSPICE_LIB),
"examples_dir": str(LTSPICE_EXAMPLES),
},
"lib_exists": LTSPICE_LIB.exists(),
"examples_exist": LTSPICE_EXAMPLES.exists(),
}
# ============================================================================
# RESOURCES
# ============================================================================
@mcp.resource("ltspice://symbols")
def resource_symbols() -> str:
"""List of all available LTspice symbols organized by category."""
result = list_symbols(limit=10000)
return json.dumps(result, indent=2)
@mcp.resource("ltspice://examples")
def resource_examples() -> str:
"""List of all LTspice example circuits."""
result = list_examples(limit=10000)
return json.dumps(result, indent=2)
@mcp.resource("ltspice://status")
def resource_status() -> str:
"""Current LTspice installation status."""
return json.dumps(check_installation(), indent=2)
# ============================================================================
# ENTRY POINT
# ============================================================================
def main():
"""Run the MCP server."""
print(f"🔌 mcp-ltspice v{__version__}")
print(" LTspice circuit simulation automation")
# Quick validation
ok, msg = validate_installation()
if ok:
print(f"{msg}")
else:
print(f"{msg}")
mcp.run()
if __name__ == "__main__":
main()

2126
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff