"""FastMCP server for LTspice circuit simulation automation. This server provides tools for: - Running SPICE simulations on schematics and netlists - Extracting and analyzing waveform data - Creating circuits programmatically - Modifying schematic components - Browsing component libraries, models, and examples - Design rule checks and circuit comparison """ import csv import io import json import math import tempfile from pathlib import Path import numpy as np from fastmcp import FastMCP from . import __version__ from .asc_generator import ( generate_inverting_amp, ) from .asc_generator import ( generate_rc_lowpass as generate_rc_lowpass_asc, ) from .asc_generator import ( generate_voltage_divider as generate_voltage_divider_asc, ) from .batch import ( run_monte_carlo, run_parameter_sweep, run_temperature_sweep, ) from .config import ( LTSPICE_EXAMPLES, LTSPICE_LIB, validate_installation, ) from .diff import diff_schematics as _diff_schematics from .drc import run_drc as _run_drc from .log_parser import parse_log from .models import ( search_models as _search_models, ) from .models import ( search_subcircuits as _search_subcircuits, ) from .netlist import Netlist from .optimizer import ( ComponentRange, OptimizationTarget, format_engineering, optimize_component_values, ) 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 .stability import compute_stability_metrics from .touchstone import parse_touchstone, s_param_to_db from .waveform_expr import WaveformCalculator from .waveform_math import ( compute_bandwidth, compute_fft, compute_peak_to_peak, compute_rise_time, compute_rms, compute_settling_time, compute_thd, ) 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 - Analyze signals: FFT, THD, RMS, bandwidth, settling time - Create circuits from scratch using the netlist builder - 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 - 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 - Parse Touchstone (.s2p) S-parameter files - Use circuit templates: buck converter, LDO, diff amp, oscillator, H-bridge LTspice runs via Wine on Linux. Simulations execute in batch mode and results are parsed from binary .raw files. """, ) # ============================================================================ # SIMULATION TOOLS # ============================================================================ @mcp.tool() async def simulate( schematic_path: str, timeout_seconds: float = 300, ) -> dict: """Run an LTspice simulation on a schematic file. Executes any simulation directives (.tran, .ac, .dc, .op, etc.) found in the schematic. Returns available signal names and the path to the .raw file for waveform extraction. Args: schematic_path: Absolute path to .asc schematic file timeout_seconds: Maximum time to wait for simulation (default 5 min) """ 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 if result.log_file and result.log_file.exists(): log = parse_log(result.log_file) if log.measurements: response["measurements"] = log.get_all_measurements() if log.errors: response["log_errors"] = log.errors return response @mcp.tool() async def simulate_netlist( netlist_path: str, timeout_seconds: float = 300, ) -> dict: """Run an LTspice simulation on a netlist file (.cir or .net). Args: netlist_path: Absolute path to .cir or .net netlist file timeout_seconds: Maximum time to wait for simulation """ 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 if result.log_file and result.log_file.exists(): log = parse_log(result.log_file) if log.measurements: response["measurements"] = log.get_all_measurements() if log.errors: response["log_errors"] = log.errors return response # ============================================================================ # WAVEFORM & ANALYSIS TOOLS # ============================================================================ @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. For transient analysis, returns time + voltage/current values. For AC analysis, returns frequency + magnitude(dB)/phase(degrees). Args: raw_file_path: Path to .raw file from simulation signal_names: Signal names to extract, e.g. ["V(out)", "I(R1)"] max_points: Maximum data points (downsampled if needed) """ raw = parse_raw_file(raw_file_path) x_axis = raw.get_time() x_name = "time" if x_axis is None: x_axis = raw.get_frequency() x_name = "frequency" 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] if np.iscomplexobj(sampled): result["x_axis_data"] = sampled.real.tolist() else: result["x_axis_data"] = sampled.tolist() result["returned_points"] = len(result["x_axis_data"]) for name in signal_names: data = raw.get_variable(name) if data is not None: sampled = data[::step] if np.iscomplexobj(sampled): result["signals"][name] = { "magnitude_db": [ 20 * math.log10(abs(x)) if abs(x) > 0 else -200 for x in sampled ], "phase_degrees": [math.degrees(math.atan2(x.imag, x.real)) for x in sampled], } else: result["signals"][name] = {"values": sampled.tolist()} return result @mcp.tool() def analyze_waveform( raw_file_path: str, signal_name: str, analyses: list[str], settling_tolerance_pct: float = 2.0, settling_final_value: float | None = None, rise_low_pct: float = 10.0, rise_high_pct: float = 90.0, fft_max_harmonics: int = 50, thd_n_harmonics: int = 10, ) -> dict: """Analyze a signal from simulation results. Run one or more analyses on a waveform. Available analyses: - "rms": Root mean square value - "peak_to_peak": Min, max, peak-to-peak swing, mean - "settling_time": Time to settle within tolerance of final value - "rise_time": 10%-90% rise time (configurable) - "fft": Frequency spectrum via FFT - "thd": Total Harmonic Distortion Args: raw_file_path: Path to .raw file signal_name: Signal to analyze, e.g. "V(out)" analyses: List of analysis types to run settling_tolerance_pct: Tolerance for settling time (default 2%) settling_final_value: Target value (None = use last sample) rise_low_pct: Low threshold for rise time (default 10%) rise_high_pct: High threshold for rise time (default 90%) fft_max_harmonics: Max harmonics to return in FFT thd_n_harmonics: Number of harmonics for THD calculation """ raw = parse_raw_file(raw_file_path) time = raw.get_time() signal = raw.get_variable(signal_name) if signal is None: return { "error": f"Signal '{signal_name}' not found. Available: " f"{[v.name for v in raw.variables]}" } # Use real parts for time-domain analysis if np.iscomplexobj(time): time = time.real if np.iscomplexobj(signal): signal = np.abs(signal) results = {"signal": signal_name} for analysis in analyses: if analysis == "rms": results["rms"] = compute_rms(signal) elif analysis == "peak_to_peak": results["peak_to_peak"] = compute_peak_to_peak(signal) elif analysis == "settling_time": if time is not None: results["settling_time"] = compute_settling_time( time, signal, final_value=settling_final_value, tolerance_percent=settling_tolerance_pct, ) elif analysis == "rise_time": if time is not None: results["rise_time"] = compute_rise_time( time, signal, low_pct=rise_low_pct, high_pct=rise_high_pct, ) elif analysis == "fft": if time is not None: results["fft"] = compute_fft( time, signal, max_harmonics=fft_max_harmonics, ) elif analysis == "thd": if time is not None: results["thd"] = compute_thd( time, signal, n_harmonics=thd_n_harmonics, ) return results @mcp.tool() def measure_bandwidth( raw_file_path: str, signal_name: str, ref_db: float | None = None, ) -> dict: """Measure -3dB bandwidth from an AC analysis result. Computes the frequency range where the signal is within 3dB of its peak (or a specified reference level). Args: raw_file_path: Path to .raw file from AC simulation signal_name: Signal to measure, e.g. "V(out)" ref_db: Reference level in dB (None = use peak) """ raw = parse_raw_file(raw_file_path) freq = raw.get_frequency() signal = raw.get_variable(signal_name) if freq is None: return {"error": "Not an AC analysis - no frequency data found"} if signal is None: return {"error": f"Signal '{signal_name}' not found"} # Convert complex signal to magnitude in dB mag_db = np.array([20 * math.log10(abs(x)) if abs(x) > 0 else -200 for x in signal]) return compute_bandwidth(freq.real, mag_db, ref_db=ref_db) @mcp.tool() def export_csv( raw_file_path: str, signal_names: list[str] | None = None, output_path: str | None = None, max_points: int = 10000, ) -> dict: """Export simulation waveform data to CSV format. Args: raw_file_path: Path to .raw file signal_names: Signals to export (None = all) output_path: Where to save CSV (None = auto-generate in /tmp) max_points: Maximum rows to export """ raw = parse_raw_file(raw_file_path) # Determine x-axis x_axis = raw.get_time() x_name = "time" if x_axis is None: x_axis = raw.get_frequency() x_name = "frequency" # Select signals if signal_names is None: signal_names = [ v.name for v in raw.variables if v.name not in (x_name, "time", "frequency") ] # Downsample total = raw.points step = max(1, total // max_points) # Build CSV buf = io.StringIO() writer = csv.writer(buf) # Header if x_axis is not None and np.iscomplexobj(x_axis): headers = [x_name] else: headers = [x_name] for name in signal_names: data = raw.get_variable(name) if data is not None: if np.iscomplexobj(data): headers.extend([f"{name}_magnitude_db", f"{name}_phase_deg"]) else: headers.append(name) writer.writerow(headers) # Data rows indices = range(0, total, step) for i in indices: row = [] if x_axis is not None: row.append(x_axis[i].real if np.iscomplexobj(x_axis) else x_axis[i]) for name in signal_names: data = raw.get_variable(name) if data is not None: if np.iscomplexobj(data): val = data[i] row.append(20 * math.log10(abs(val)) if abs(val) > 0 else -200) row.append(math.degrees(math.atan2(val.imag, val.real))) else: row.append(data[i]) writer.writerow(row) csv_content = buf.getvalue() # Save to file if output_path is None: raw_name = Path(raw_file_path).stem output_path = str(Path(tempfile.gettempdir()) / f"{raw_name}.csv") Path(output_path).write_text(csv_content) return { "output_path": output_path, "rows": len(indices), "columns": headers, } # ============================================================================ # STABILITY ANALYSIS TOOLS # ============================================================================ @mcp.tool() def analyze_stability( raw_file_path: str, signal_name: str, ) -> dict: """Measure gain margin and phase margin from AC loop gain data. Computes Bode plot (magnitude + phase) and finds the crossover frequencies where gain = 0 dB and phase = -180 degrees. Args: raw_file_path: Path to .raw file from AC simulation signal_name: Loop gain signal, e.g. "V(out)" or "V(loop_gain)" """ raw = parse_raw_file(raw_file_path) freq = raw.get_frequency() signal = raw.get_variable(signal_name) if freq is None: return {"error": "Not an AC analysis - no frequency data found"} if signal is None: return { "error": f"Signal '{signal_name}' not found. Available: " f"{[v.name for v in raw.variables]}" } return compute_stability_metrics(freq.real, signal) # ============================================================================ # POWER ANALYSIS TOOLS # ============================================================================ @mcp.tool() def analyze_power( raw_file_path: str, voltage_signal: str, current_signal: str, ) -> dict: """Compute power metrics from voltage and current waveforms. Returns average power, RMS power, peak power, and power factor. Args: raw_file_path: Path to .raw file from transient simulation voltage_signal: Voltage signal name, e.g. "V(out)" current_signal: Current signal name, e.g. "I(R1)" """ raw = parse_raw_file(raw_file_path) time = raw.get_time() voltage = raw.get_variable(voltage_signal) current = raw.get_variable(current_signal) if time is None: return {"error": "Not a transient analysis - no time data found"} if voltage is None: return {"error": f"Voltage signal '{voltage_signal}' not found"} if current is None: return {"error": f"Current signal '{current_signal}' not found"} return compute_power_metrics(time, voltage, current) @mcp.tool() def compute_efficiency_tool( raw_file_path: str, input_voltage_signal: str, input_current_signal: str, output_voltage_signal: str, output_current_signal: str, ) -> dict: """Compute power conversion efficiency. Compares input power to output power for regulators, converters, etc. Args: raw_file_path: Path to .raw file from transient simulation input_voltage_signal: Input voltage, e.g. "V(vin)" input_current_signal: Input current, e.g. "I(Vin)" output_voltage_signal: Output voltage, e.g. "V(out)" output_current_signal: Output current, e.g. "I(Rload)" """ raw = parse_raw_file(raw_file_path) time = raw.get_time() if time is None: return {"error": "Not a transient analysis"} v_in = raw.get_variable(input_voltage_signal) i_in = raw.get_variable(input_current_signal) v_out = raw.get_variable(output_voltage_signal) i_out = raw.get_variable(output_current_signal) for name, sig in [ (input_voltage_signal, v_in), (input_current_signal, i_in), (output_voltage_signal, v_out), (output_current_signal, i_out), ]: if sig is None: return {"error": f"Signal '{name}' not found"} return compute_efficiency(time, v_in, i_in, v_out, i_out) # ============================================================================ # WAVEFORM EXPRESSION TOOLS # ============================================================================ @mcp.tool() def evaluate_waveform_expression( raw_file_path: str, expression: str, max_points: int = 1000, ) -> dict: """Evaluate a math expression on simulation waveforms. Supports: +, -, *, /, abs(), sqrt(), log10(), dB() Signal names reference variables from the .raw file. Examples: "V(out) * I(R1)" - instantaneous power "V(out) / V(in)" - voltage gain "dB(V(out))" - magnitude in dB Args: raw_file_path: Path to .raw file expression: Math expression using signal names max_points: Maximum data points to return """ raw = parse_raw_file(raw_file_path) calc = WaveformCalculator(raw) try: result = calc.calc(expression) except ValueError as e: return {"error": str(e), "available_signals": calc.available_signals()} # Get x-axis x_axis = raw.get_time() x_name = "time" if x_axis is None: x_axis = raw.get_frequency() x_name = "frequency" total = len(result) step = max(1, total // max_points) response = { "expression": expression, "total_points": total, "returned_points": len(result[::step]), } if x_axis is not None: sampled_x = x_axis[::step] response["x_axis_name"] = x_name response["x_axis_data"] = ( sampled_x.real.tolist() if np.iscomplexobj(sampled_x) else sampled_x.tolist() ) response["values"] = result[::step].tolist() response["available_signals"] = calc.available_signals() return response # ============================================================================ # OPTIMIZER TOOLS # ============================================================================ @mcp.tool() async def optimize_circuit( netlist_template: str, targets: list[dict], component_ranges: list[dict], max_iterations: int = 20, ) -> dict: """Automatically optimize component values to hit target specifications. Runs real LTspice simulations in a loop, adjusting component values using binary search (single component) or coordinate descent (multiple). Args: netlist_template: Netlist text with {ComponentName} placeholders (e.g., {R1}, {C1}) that get substituted each iteration. targets: List of target specs, each with: - signal_name: Signal to measure (e.g., "V(out)") - metric: One of "bandwidth_hz", "rms", "peak_to_peak", "settling_time", "gain_db", "phase_margin_deg" - target_value: Desired value - weight: Importance weight (default 1.0) component_ranges: List of tunable components, each with: - component_name: Name matching {placeholder} (e.g., "R1") - min_value: Minimum value in base units - max_value: Maximum value in base units - preferred_series: Optional "E12", "E24", or "E96" for snapping max_iterations: Max simulation iterations (default 20) """ opt_targets = [ OptimizationTarget( signal_name=t["signal_name"], metric=t["metric"], target_value=t["target_value"], weight=t.get("weight", 1.0), ) for t in targets ] opt_ranges = [ ComponentRange( component_name=r["component_name"], min_value=r["min_value"], max_value=r["max_value"], preferred_series=r.get("preferred_series"), ) for r in component_ranges ] result = await optimize_component_values( netlist_template, opt_targets, opt_ranges, max_iterations, ) return { "best_values": {k: format_engineering(v) for k, v in result.best_values.items()}, "best_values_raw": result.best_values, "best_cost": result.best_cost, "iterations": result.iterations, "targets_met": result.targets_met, "final_metrics": result.final_metrics, "history_length": len(result.history), } # ============================================================================ # BATCH SIMULATION TOOLS # ============================================================================ @mcp.tool() async def parameter_sweep( netlist_text: str, param_name: str, start: float, stop: float, num_points: int = 10, timeout_seconds: float = 300, ) -> dict: """Sweep a parameter across a range of values. Runs multiple simulations, substituting the parameter value each time. The netlist should contain a .param directive for the parameter. Args: netlist_text: Netlist with .param directive param_name: Parameter to sweep (e.g., "Rval") start: Start value stop: Stop value num_points: Number of sweep points timeout_seconds: Per-simulation timeout """ values = np.linspace(start, stop, num_points).tolist() result = await run_parameter_sweep( netlist_text, param_name, values, timeout=timeout_seconds, ) return { "success_count": result.success_count, "failure_count": result.failure_count, "total_elapsed": result.total_elapsed, "parameter_values": result.parameter_values, "raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results], } @mcp.tool() async def temperature_sweep( netlist_text: str, temperatures: list[float], timeout_seconds: float = 300, ) -> dict: """Run simulations at different temperatures. Args: netlist_text: Netlist text temperatures: List of temperatures in degrees C timeout_seconds: Per-simulation timeout """ result = await run_temperature_sweep( netlist_text, temperatures, timeout=timeout_seconds, ) return { "success_count": result.success_count, "failure_count": result.failure_count, "total_elapsed": result.total_elapsed, "parameter_values": result.parameter_values, "raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results], } @mcp.tool() async def monte_carlo( netlist_text: str, n_runs: int, tolerances: dict[str, float], timeout_seconds: float = 300, seed: int | None = None, ) -> dict: """Run Monte Carlo analysis with component tolerances. Randomly varies component values within tolerance using a normal distribution, then runs simulations for each variant. Args: netlist_text: Netlist text n_runs: Number of Monte Carlo iterations tolerances: Component tolerances, e.g. {"R1": 0.05} for 5% timeout_seconds: Per-simulation timeout seed: Optional RNG seed for reproducibility """ result = await run_monte_carlo( netlist_text, n_runs, tolerances, timeout=timeout_seconds, seed=seed, ) return { "success_count": result.success_count, "failure_count": result.failure_count, "total_elapsed": result.total_elapsed, "parameter_values": result.parameter_values, "raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results], } # ============================================================================ # SCHEMATIC GENERATION TOOLS # ============================================================================ @mcp.tool() def generate_schematic( template: str, output_path: str | None = None, r: str | None = None, c: str | None = None, r1: str | None = None, r2: str | None = None, vin: str | None = None, rin: str | None = None, rf: str | None = None, opamp_model: str | None = None, ) -> dict: """Generate an LTspice .asc schematic file from a template. Available templates and their parameters: - "rc_lowpass": r (resistor, default "1k"), c (capacitor, default "100n") - "voltage_divider": r1 (top, default "10k"), r2 (bottom, default "10k"), vin (input voltage, default "5") - "inverting_amp": rin (input R, default "10k"), rf (feedback R, default "100k"), opamp_model (default "UniversalOpamp2") Args: template: Template name output_path: Where to save (None = auto in /tmp) r: Resistor value (rc_lowpass) c: Capacitor value (rc_lowpass) r1: Top resistor (voltage_divider) r2: Bottom resistor (voltage_divider) vin: Input voltage (voltage_divider) rin: Input resistor (inverting_amp) rf: Feedback resistor (inverting_amp) opamp_model: Op-amp model name (inverting_amp) """ if template == "rc_lowpass": params: dict[str, str] = {} if r is not None: params["r"] = r if c is not None: params["c"] = c sch = generate_rc_lowpass_asc(**params) elif template == "voltage_divider": params = {} if r1 is not None: params["r1"] = r1 if r2 is not None: params["r2"] = r2 if vin is not None: params["vin"] = vin sch = generate_voltage_divider_asc(**params) elif template == "inverting_amp": params = {} if rin is not None: params["rin"] = rin if rf is not None: params["rf"] = rf if opamp_model is not None: params["opamp_model"] = opamp_model sch = generate_inverting_amp(**params) else: return { "error": f"Unknown template '{template}'. " f"Available: rc_lowpass, voltage_divider, inverting_amp" } if output_path is None: output_path = str(Path(tempfile.gettempdir()) / f"{template}.asc") saved = sch.save(output_path) return { "success": True, "output_path": str(saved), "schematic_preview": sch.render()[:500], } # ============================================================================ # TOUCHSTONE / S-PARAMETER TOOLS # ============================================================================ @mcp.tool() def read_touchstone(file_path: str) -> dict: """Parse a Touchstone (.s1p, .s2p, .snp) S-parameter file. Returns S-parameter data, frequency points, and port information. Args: file_path: Path to Touchstone file """ try: data = parse_touchstone(file_path) except (ValueError, FileNotFoundError) as e: return {"error": str(e)} # Convert S-parameter data to a more digestible format s_params = {} for i in range(data.n_ports): for j in range(data.n_ports): key = f"S{i + 1}{j + 1}" s_data = data.data[:, i, j] s_params[key] = { "magnitude_db": s_param_to_db(s_data).tolist(), } return { "filename": data.filename, "n_ports": data.n_ports, "n_frequencies": len(data.frequencies), "freq_range_hz": [float(data.frequencies[0]), float(data.frequencies[-1])], "reference_impedance": data.reference_impedance, "s_parameters": s_params, "comments": data.comments[:5], # First 5 comment lines } # ============================================================================ # SCHEMATIC TOOLS # ============================================================================ @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 """ 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. 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 (None = overwrite original) """ 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 diff_schematics( schematic_a: str, schematic_b: str, ) -> dict: """Compare two schematics and show what changed. Reports component additions, removals, value changes, directive changes, and wire/net topology differences. Args: schematic_a: Path to "before" .asc file schematic_b: Path to "after" .asc file """ diff = _diff_schematics(schematic_a, schematic_b) return diff.to_dict() @mcp.tool() def run_drc(schematic_path: str) -> dict: """Run design rule checks on a schematic. Checks for common issues: - Missing ground connection - Floating nodes - Missing simulation directive - Voltage source loops - Missing component values - Duplicate component names - Unconnected components Args: schematic_path: Path to .asc schematic file """ result = _run_drc(schematic_path) return result.to_dict() # ============================================================================ # NETLIST BUILDER TOOLS # ============================================================================ @mcp.tool() def create_netlist( title: str, components: list[dict], directives: list[str], output_path: str | None = None, ) -> dict: """Create a SPICE netlist programmatically and save to a .cir file. Build circuits from scratch without needing a graphical schematic. The created .cir file can be simulated with simulate_netlist. Args: title: Circuit title/description components: List of component dicts, each with: - name: Component name (R1, C1, V1, M1, X1, etc.) - nodes: List of node names (use "0" for ground) - value: Value or model name - params: Optional extra parameters string directives: List of SPICE directives, e.g.: [".tran 10m", ".ac dec 100 1 1meg", ".meas tran vmax MAX V(out)"] output_path: Where to save .cir file (None = auto in /tmp) Example components: [ {"name": "V1", "nodes": ["in", "0"], "value": "AC 1"}, {"name": "R1", "nodes": ["in", "out"], "value": "10k"}, {"name": "C1", "nodes": ["out", "0"], "value": "100n"} ] """ nl = Netlist(title=title) for comp in components: nl.add_component( name=comp["name"], nodes=comp["nodes"], value=comp["value"], params=comp.get("params", ""), ) for directive in directives: nl.add_directive(directive) # Determine output path if output_path is None: safe_title = "".join(c if c.isalnum() else "_" for c in title)[:30] output_path = str(Path(tempfile.gettempdir()) / f"{safe_title}.cir") saved = nl.save(output_path) return { "success": True, "output_path": str(saved), "netlist_preview": nl.render(), "component_count": len(nl.components), } # ============================================================================ # LIBRARY & MODEL TOOLS # ============================================================================ @mcp.tool() def list_symbols( category: str | None = None, search: str | None = None, limit: int = 50, ) -> dict: """List available component symbols from LTspice library. Args: category: Filter by category (e.g., "Opamps", "Comparators") search: Search term for symbol name (case-insensitive) limit: Maximum results to return """ 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 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)}) 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. Args: category: Filter by category folder search: Search term for example name limit: Maximum results to return """ 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 """ 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 search_spice_models( search: str | None = None, model_type: str | None = None, limit: int = 50, ) -> dict: """Search for SPICE .model definitions in the library. Finds transistors, diodes, and other discrete devices. Args: search: Search term for model name (case-insensitive) model_type: Filter by type: NPN, PNP, NMOS, PMOS, D, NJF, PJF limit: Maximum results """ models = _search_models(search=search, model_type=model_type, limit=limit) return { "models": [ { "name": m.name, "type": m.type, "source_file": m.source_file, "parameters": m.parameters, } for m in models ], "total_count": len(models), } @mcp.tool() def search_spice_subcircuits( search: str | None = None, limit: int = 50, ) -> dict: """Search for SPICE .subckt definitions (op-amps, ICs, etc.). Args: search: Search term for subcircuit name limit: Maximum results """ subs = _search_subcircuits(search=search, limit=limit) return { "subcircuits": [ { "name": s.name, "pins": s.pins, "pin_names": s.pin_names, "description": s.description, "source_file": s.source_file, "n_components": s.n_components, } for s in subs ], "total_count": len(subs), } @mcp.tool() def check_installation() -> dict: """Verify LTspice and Wine are properly installed.""" 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: """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: """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) # ============================================================================ # PROMPTS # ============================================================================ @mcp.prompt() def design_filter( filter_type: str = "lowpass", topology: str = "rc", cutoff_freq: str = "1kHz", ) -> str: """Guide through designing and simulating a filter circuit. Args: filter_type: lowpass, highpass, bandpass, or notch topology: rc (1st order), rlc (2nd order), or sallen-key (active) cutoff_freq: Target cutoff frequency with units """ return f"""Design a {filter_type} filter with these requirements: - Topology: {topology} - Cutoff frequency: {cutoff_freq} Workflow: 1. Use create_netlist to build the circuit 2. Add .ac analysis directive for frequency sweep 3. Add .meas directive for -3dB bandwidth 4. Simulate with simulate_netlist 5. Use measure_bandwidth to verify cutoff frequency 6. Use get_waveform to inspect the frequency response 7. Adjust component values with create_netlist if needed Tips: - For RC lowpass: f_c = 1/(2*pi*R*C) - For 2nd order: Q controls peaking, Butterworth Q=0.707 - Use search_spice_models to find op-amp models for active filters """ @mcp.prompt() def analyze_power_supply(schematic_path: str = "") -> str: """Guide through analyzing a power supply circuit. Args: schematic_path: Path to the power supply schematic """ path_instruction = ( f"The schematic is at: {schematic_path}" if schematic_path else "First, identify or create the power supply schematic." ) return f"""Analyze a power supply circuit for key performance metrics. {path_instruction} Workflow: 1. Use read_schematic to understand the circuit topology 2. Use run_drc to check for design issues 3. Simulate with .tran analysis (include load step if applicable) 4. Use analyze_waveform with these analyses: - "peak_to_peak" on output for ripple measurement - "settling_time" for transient response - "fft" on output to identify noise frequencies 5. If AC analysis available, use measure_bandwidth for loop gain Key metrics to extract: - Output voltage regulation (DC accuracy) - Ripple voltage (peak-to-peak on output) - Load transient response (settling time after step) - Efficiency (input power vs output power) """ @mcp.prompt() def debug_circuit(schematic_path: str = "") -> str: """Guide through debugging a circuit that isn't working. Args: schematic_path: Path to the problematic schematic """ path_instruction = ( f"The schematic is at: {schematic_path}" if schematic_path else "First, identify the schematic file." ) return f"""Systematic approach to debugging a circuit. {path_instruction} Step 1 - Validate the schematic: - Use run_drc to catch obvious issues (missing ground, floating nodes) - Use read_schematic to review component values and connections Step 2 - Check simulation setup: - Verify simulation directives are correct - Check that models/subcircuits are available (search_spice_models) Step 3 - Run and analyze: - Simulate the circuit - Use get_waveform to inspect key node voltages - Compare expected vs actual values at each stage Step 4 - Isolate the problem: - Use edit_component to simplify (replace active devices with ideal) - Use diff_schematics to track what changes fixed the issue - Re-simulate after each change Common issues: - Wrong node connections (check wire endpoints) - Missing bias voltages or ground - Component values off by orders of magnitude - Wrong model (check with search_spice_models) """ # ============================================================================ # ENTRY POINT # ============================================================================ def main(): """Run the MCP server.""" print(f"\U0001f50c mcp-ltspice v{__version__}") print(" LTspice circuit simulation automation") ok, msg = validate_installation() if ok: print(f" \u2713 {msg}") else: print(f" \u26a0 {msg}") mcp.run() if __name__ == "__main__": main()