"""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, buck_converter, colpitts_oscillator, common_emitter_amplifier, differential_amplifier, h_bridge, inverting_amplifier, ldo_regulator, non_inverting_amplifier, rc_lowpass, voltage_divider, ) 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 - 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 - 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 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, run: int | None = None, ) -> 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). For stepped simulations (.step, .mc, .temp), specify `run` (1-based) to extract a single run's data. Omit `run` to get all data combined. 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) run: Run number (1-based) for stepped simulations (None = all data) """ raw = parse_raw_file(raw_file_path) # Extract specific run if requested if run is not None: if not raw.is_stepped: return {"error": "Not a stepped simulation - no multiple runs available"} if run < 1 or run > raw.n_runs: return {"error": f"Run {run} out of range (1..{raw.n_runs})"} raw = raw.get_run_data(run) 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, "is_stepped": raw.is_stepped, "n_runs": raw.n_runs, } 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 list_simulation_runs(raw_file_path: str) -> dict: """List runs in a stepped simulation (.step, .mc, .temp). Returns run count and boundary information for multi-run .raw files. Args: raw_file_path: Path to .raw file from simulation """ raw = parse_raw_file(raw_file_path) result = { "is_stepped": raw.is_stepped, "n_runs": raw.n_runs, "total_points": raw.points, "plotname": raw.plotname, "variables": [{"name": v.name, "type": v.type} for v in raw.variables], } if raw.is_stepped and raw.run_boundaries: runs = [] for i in range(raw.n_runs): start, end = raw._run_slice(i + 1) runs.append( { "run": i + 1, "start_index": start, "end_index": end, "points": end - start, } ) result["runs"] = runs 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) # ============================================================================ # DC OPERATING POINT & TRANSFER FUNCTION TOOLS # ============================================================================ @mcp.tool() def get_operating_point(log_file_path: str) -> dict: """Extract DC operating point results from a simulation log. The .op analysis computes all node voltages and branch currents at the DC bias point. Results include device operating points (transistor Gm, Id, Vgs, etc.) when available. Run a simulation with .op directive first, then pass the log file. Args: log_file_path: Path to .log file from simulation """ log = parse_log(log_file_path) if not log.operating_point: return { "error": "No operating point data found in log. " "Ensure the simulation uses a .op directive.", "log_errors": log.errors, } # Separate node voltages from branch currents/device params voltages = {} currents = {} other = {} for name, value in log.operating_point.items(): if name.startswith("V(") or name.startswith("v("): voltages[name] = value elif name.startswith("I(") or name.startswith("i(") or name.startswith("Ix("): currents[name] = value else: other[name] = value return { "voltages": voltages, "currents": currents, "device_params": other, "total_entries": len(log.operating_point), } @mcp.tool() def get_transfer_function(log_file_path: str) -> dict: """Extract .tf (transfer function) results from a simulation log. The .tf analysis computes: - Transfer function (gain or transresistance) - Input impedance at the source - Output impedance at the output node Run a simulation with .tf directive first (e.g., ".tf V(out) V1"), then pass the log file. Args: log_file_path: Path to .log file from simulation """ log = parse_log(log_file_path) if not log.transfer_function: return { "error": "No transfer function data found in log. " "Ensure the simulation uses a .tf directive, " "e.g., '.tf V(out) V1'.", "log_errors": log.errors, } # Identify the specific components result: dict = {"raw_data": log.transfer_function} for name, value in log.transfer_function.items(): name_lower = name.lower() if "transfer_function" in name_lower: result["transfer_function"] = value elif "output_impedance" in name_lower: result["output_impedance_ohms"] = value elif "input_impedance" in name_lower: result["input_impedance_ohms"] = value return result # ============================================================================ # 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), } # ============================================================================ # CIRCUIT TEMPLATE TOOLS # ============================================================================ # Registry of netlist templates with parameter metadata _TEMPLATES: dict[str, dict] = { "voltage_divider": { "func": voltage_divider, "description": "Resistive voltage divider with .op or custom analysis", "params": {"v_in": "5", "r1": "10k", "r2": "10k", "sim_type": "op"}, }, "rc_lowpass": { "func": rc_lowpass, "description": "RC lowpass filter with AC sweep", "params": {"r": "1k", "c": "100n", "f_start": "1", "f_stop": "1meg"}, }, "inverting_amplifier": { "func": inverting_amplifier, "description": "Inverting op-amp (gain = -Rf/Rin), +/-15V supply", "params": {"r_in": "10k", "r_f": "100k", "opamp_model": "LT1001"}, }, "non_inverting_amplifier": { "func": non_inverting_amplifier, "description": "Non-inverting op-amp (gain = 1 + Rf/Rin), +/-15V supply", "params": {"r_in": "10k", "r_f": "100k", "opamp_model": "LT1001"}, }, "differential_amplifier": { "func": differential_amplifier, "description": "Diff amp: Vout = (R2/R1)*(V2-V1), +/-15V supply", "params": { "r1": "10k", "r2": "10k", "r3": "10k", "r4": "10k", "opamp_model": "LT1001", }, }, "common_emitter_amplifier": { "func": common_emitter_amplifier, "description": "BJT common-emitter with voltage divider bias", "params": { "rc": "2.2k", "rb1": "56k", "rb2": "12k", "re": "1k", "cc1": "10u", "cc2": "10u", "ce": "47u", "vcc": "12", "bjt_model": "2N2222", }, }, "buck_converter": { "func": buck_converter, "description": "Step-down DC-DC converter with MOSFET switch", "params": { "ind": "10u", "c_out": "100u", "r_load": "10", "v_in": "12", "duty_cycle": "0.5", "freq": "100k", "mosfet_model": "IRF540N", "diode_model": "1N5819", }, }, "ldo_regulator": { "func": ldo_regulator, "description": "LDO regulator: Vout = Vref * (1 + R1/R2)", "params": { "opamp_model": "LT1001", "r1": "10k", "r2": "10k", "pass_transistor": "IRF9540N", "v_in": "8", "v_ref": "2.5", }, }, "colpitts_oscillator": { "func": colpitts_oscillator, "description": "LC oscillator: f ~ 1/(2pi*sqrt(L*Cseries))", "params": { "ind": "1u", "c1": "100p", "c2": "100p", "rb": "47k", "rc": "1k", "re": "470", "vcc": "12", "bjt_model": "2N2222", }, }, "h_bridge": { "func": h_bridge, "description": "4-MOSFET H-bridge motor driver with dead time", "params": { "v_supply": "12", "r_load": "10", "mosfet_model": "IRF540N", }, }, } @mcp.tool() def create_from_template( template_name: str, params: dict[str, str] | None = None, output_path: str | None = None, ) -> dict: """Create a circuit netlist from a pre-built template. Available templates: - voltage_divider: params {v_in, r1, r2, sim_type} - rc_lowpass: params {r, c, f_start, f_stop} - inverting_amplifier: params {r_in, r_f, opamp_model} - non_inverting_amplifier: params {r_in, r_f, opamp_model} - differential_amplifier: params {r1, r2, r3, r4, opamp_model} - common_emitter_amplifier: params {rc, rb1, rb2, re, cc1, cc2, ce, vcc, bjt_model} - buck_converter: params {ind, c_out, r_load, v_in, duty_cycle, freq, mosfet_model, diode_model} - ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref} - colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model} - h_bridge: params {v_supply, r_load, mosfet_model} All parameter values are optional -- defaults are used if omitted. Args: template_name: Template name from the list above params: Optional dict of parameter overrides (all values as strings) output_path: Where to save .cir file (None = auto in /tmp) """ template = _TEMPLATES.get(template_name) if template is None: return { "error": f"Unknown template '{template_name}'", "available_templates": [ {"name": k, "description": v["description"], "params": v["params"]} for k, v in _TEMPLATES.items() ], } # Build kwargs from params, converting duty_cycle to float for buck_converter kwargs: dict = {} if params: for k, v in params.items(): if k not in template["params"]: return { "error": f"Unknown parameter '{k}' for template '{template_name}'", "valid_params": template["params"], } # duty_cycle needs to be float, not string if k == "duty_cycle": kwargs[k] = float(v) else: kwargs[k] = v nl = template["func"](**kwargs) if output_path is None: output_path = str(Path(tempfile.gettempdir()) / f"{template_name}.cir") saved = nl.save(output_path) return { "success": True, "template": template_name, "description": template["description"], "output_path": str(saved), "netlist_preview": nl.render(), "component_count": len(nl.components), "params_used": {**template["params"], **(params or {})}, } @mcp.tool() def list_templates() -> dict: """List all available circuit templates with their parameters and defaults. Returns template names, descriptions, and the parameters each accepts with their default values. """ return { "templates": [ { "name": name, "description": info["description"], "params": info["params"], } for name, info in _TEMPLATES.items() ], "total_count": len(_TEMPLATES), } # ============================================================================ # 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) """ @mcp.prompt() def optimize_design( circuit_type: str = "filter", target_spec: str = "1kHz bandwidth", ) -> str: """Guide through optimizing a circuit to meet target specifications. Args: circuit_type: Type of circuit (filter, amplifier, regulator, oscillator) target_spec: Target specification to achieve """ return f"""Optimize a {circuit_type} circuit to achieve: {target_spec} Workflow: 1. Start with a template: use list_templates to see available circuits 2. Create the initial circuit with create_from_template 3. Simulate and measure the current performance 4. Use optimize_circuit to automatically tune component values: - Define target metrics (bandwidth, gain, settling time, etc.) - Specify component ranges with preferred E-series values - Let the optimizer iterate (typically 10-20 simulations) 5. Verify the optimized design with a full simulation 6. Run Monte Carlo (monte_carlo tool) to check yield with tolerances Tips: - Start with reasonable initial values from the template - Use E24 or E96 series for resistors/capacitors - For filters: target bandwidth_hz metric - For amplifiers: target gain_db and phase_margin_deg - For regulators: target settling_time and peak_to_peak (ripple) """ @mcp.prompt() def monte_carlo_analysis( circuit_description: str = "RC filter", n_runs: str = "100", ) -> str: """Guide through Monte Carlo tolerance analysis. Args: circuit_description: What circuit to analyze n_runs: Number of Monte Carlo iterations """ return f"""Run Monte Carlo tolerance analysis on: {circuit_description} Number of runs: {n_runs} Workflow: 1. Create or identify the netlist for your circuit 2. Use monte_carlo tool with component tolerances: - Resistors: typically 1% (0.01) or 5% (0.05) - Capacitors: typically 10% (0.1) or 20% (0.2) - Inductors: typically 10% (0.1) 3. For each completed run, extract key metrics: - Use get_waveform on each raw file - Use analyze_waveform for RMS, peak-to-peak, etc. - Use measure_bandwidth for filter circuits 4. Compute statistics across all runs: - Mean and standard deviation of each metric - Min/max (worst case) - Yield: what percentage meet spec? Tips: - Use list_simulation_runs to understand stepped data - For stepped simulations, use get_waveform with run parameter - Start with fewer runs (10-20) to verify setup, then scale up - Set seed for reproducible results during development - Typical component tolerances: - Metal film resistors: 1% - Ceramic capacitors: 10-20% - Electrolytic capacitors: 20% - Inductors: 10-20% """ @mcp.prompt() def circuit_from_scratch( description: str = "audio amplifier", ) -> str: """Guide through creating a complete circuit from scratch. Args: description: What circuit to build """ return f"""Build a complete circuit from scratch: {description} Approach 1 - Use a template (recommended for common circuits): 1. Use list_templates to see available circuit templates 2. Use create_from_template with custom parameters 3. Simulate with simulate_netlist 4. Analyze results with get_waveform and analyze_waveform Approach 2 - Build from components: 1. Use create_netlist to define components and connections 2. Use search_spice_models to find transistor/diode models 3. Use search_spice_subcircuits to find op-amp/IC models 4. Add simulation directives (.tran, .ac, .dc, .op, .tf) 5. Simulate and analyze Approach 3 - Graphical schematic: 1. Use generate_schematic for supported topologies (rc_lowpass, voltage_divider, inverting_amp) 2. The .asc file can be opened in LTspice GUI for editing 3. Simulate with the simulate tool Verification workflow: 1. Run run_drc to check for design issues before simulating 2. Start with .op analysis to verify DC bias point 3. Run .tf analysis for gain and impedance 4. Run .ac analysis for frequency response 5. Run .tran analysis for time-domain behavior 6. Use diff_schematics to compare design iterations """ # ============================================================================ # 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()