diff --git a/src/mcltspice/library_tools.py b/src/mcltspice/library_tools.py new file mode 100644 index 0000000..3cb239c --- /dev/null +++ b/src/mcltspice/library_tools.py @@ -0,0 +1,254 @@ +"""Library & model browsing tools: symbols, examples, symbol info, model and +subcircuit search, and installation check. + +The three functions that resources also need (symbols, examples, installation) +are split into a plain *_impl function plus a thin @mcp.tool() wrapper, so both +the tool and the resource call the same callable. (A @mcp.tool()-decorated name +is a FunctionTool and is NOT callable directly.) +""" + +from pathlib import Path + +from ._app import mcp +from .config import ( + LTSPICE_DIR, + LTSPICE_EXAMPLES, + LTSPICE_EXE, + LTSPICE_LIB, + WINE_PREFIX, + validate_installation, +) +from .models import search_models as _search_models +from .models import search_subcircuits as _search_subcircuits + + +def list_symbols_impl( + category: str | None = None, + search: str | None = None, + limit: int = 50, +) -> dict: + """Core logic for list_symbols (callable; the tool wrapper delegates here).""" + 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)} + + +def list_examples_impl( + category: str | None = None, + search: str | None = None, + limit: int = 50, +) -> dict: + """Core logic for list_examples (callable; the tool wrapper delegates here).""" + 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)} + + +def check_installation_impl() -> dict: + """Core logic for check_installation (callable; the tool wrapper delegates here).""" + ok, msg = validate_installation() + 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(), + } + + +@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 + """ + return list_symbols_impl(category=category, search=search, limit=limit) + + +@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 + """ + return list_examples_impl(category=category, search=search, limit=limit) + + +@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.""" + return check_installation_impl() diff --git a/src/mcltspice/resources.py b/src/mcltspice/resources.py new file mode 100644 index 0000000..df4ff66 --- /dev/null +++ b/src/mcltspice/resources.py @@ -0,0 +1,73 @@ +"""MCP resources exposing library inventories and templates as JSON. + +These call the *_impl functions in library_tools (not the @mcp.tool() wrappers, +which are FunctionTool objects and not callable) so a resource read returns data +instead of raising TypeError. +""" + +import json + +from ._app import mcp +from .library_tools import check_installation_impl, list_examples_impl, list_symbols_impl +from .templates import ASC_TEMPLATES, NETLIST_TEMPLATES + + +@mcp.resource("ltspice://symbols") +def resource_symbols() -> str: + """All available LTspice symbols organized by category.""" + return json.dumps(list_symbols_impl(limit=10000), indent=2) + + +@mcp.resource("ltspice://examples") +def resource_examples() -> str: + """All LTspice example circuits.""" + return json.dumps(list_examples_impl(limit=10000), indent=2) + + +@mcp.resource("ltspice://status") +def resource_status() -> str: + """Current LTspice installation status.""" + return json.dumps(check_installation_impl(), indent=2) + + +@mcp.resource("ltspice://templates") +def resource_templates() -> str: + """All available circuit templates (netlist and schematic) with parameters.""" + templates = { + "netlist_templates": [ + {"name": name, "description": info["description"], "params": info["params"]} + for name, info in NETLIST_TEMPLATES.items() + ], + "schematic_templates": [ + {"name": name, "description": info["description"], "params": info["params"]} + for name, info in ASC_TEMPLATES.items() + ], + } + return json.dumps(templates, indent=2) + + +@mcp.resource("ltspice://template/{name}") +def resource_template_detail(name: str) -> str: + """Detailed information about a specific circuit template.""" + # Check both registries + netlist_info = NETLIST_TEMPLATES.get(name) + asc_info = ASC_TEMPLATES.get(name) + + if not netlist_info and not asc_info: + return json.dumps({"error": f"Template '{name}' not found"}) + + result = {"name": name} + if netlist_info: + result["netlist"] = { + "description": netlist_info["description"], + "params": netlist_info["params"], + "type": "netlist (.cir)", + } + if asc_info: + result["schematic"] = { + "description": asc_info["description"], + "params": asc_info["params"], + "type": "schematic (.asc)", + } + + return json.dumps(result, indent=2) diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py index 1181a66..a57c96b 100644 --- a/src/mcltspice/server.py +++ b/src/mcltspice/server.py @@ -11,7 +11,6 @@ This server provides tools for: import csv import io -import json import math import tempfile from pathlib import Path @@ -21,23 +20,16 @@ from fastmcp.prompts import Message # Domain modules register their tools/resources/prompts on the shared mcp # instance at import time. Imported for that side effect only. -from . import spicebook_tools # noqa: F401,E402 +from . import library_tools, resources, spicebook_tools # noqa: F401,E402 from ._app import mcp 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 .noise_analysis import ( compute_noise_metrics, @@ -1563,290 +1555,6 @@ def list_templates() -> dict: } -# ============================================================================ -# 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) - - -@mcp.resource("ltspice://templates") -def resource_templates() -> str: - """All available circuit templates (netlist and schematic) with parameters.""" - templates = { - "netlist_templates": [ - {"name": name, "description": info["description"], "params": info["params"]} - for name, info in NETLIST_TEMPLATES.items() - ], - "schematic_templates": [ - {"name": name, "description": info["description"], "params": info["params"]} - for name, info in ASC_TEMPLATES.items() - ], - } - return json.dumps(templates, indent=2) - - -@mcp.resource("ltspice://template/{name}") -def resource_template_detail(name: str) -> str: - """Detailed information about a specific circuit template.""" - # Check both registries - netlist_info = NETLIST_TEMPLATES.get(name) - asc_info = ASC_TEMPLATES.get(name) - - if not netlist_info and not asc_info: - return json.dumps({"error": f"Template '{name}' not found"}) - - result = {"name": name} - if netlist_info: - result["netlist"] = { - "description": netlist_info["description"], - "params": netlist_info["params"], - "type": "netlist (.cir)", - } - if asc_info: - result["schematic"] = { - "description": asc_info["description"], - "params": asc_info["params"], - "type": "schematic (.asc)", - } - - return json.dumps(result, indent=2) - - # ============================================================================ # PROMPTS # ============================================================================ diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..1d18b9e --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,59 @@ +"""Resource-read regression tests. + +ltspice://symbols, ltspice://examples, and ltspice://status previously raised +TypeError on read because the resource functions called @mcp.tool()-decorated +names (FunctionTool objects are not callable). These tests read every resource +to guard against that regressing. +""" + +import inspect +import json + +from mcltspice.library_tools import ( + check_installation_impl, + list_examples_impl, + list_symbols_impl, +) +from mcltspice.server import mcp # importing registers all resources + + +async def _read(uri: str) -> str: + resources = await mcp.get_resources() + assert uri in resources, f"{uri} not registered" + out = resources[uri].fn() + if inspect.isawaitable(out): + out = await out + return out + + +class TestResourceReadsWithoutError: + async def test_symbols_resource_reads(self): + out = await _read("ltspice://symbols") + json.loads(out) # valid JSON, no TypeError + + async def test_examples_resource_reads(self): + out = await _read("ltspice://examples") + json.loads(out) + + async def test_status_resource_reads(self): + out = await _read("ltspice://status") + data = json.loads(out) + assert "valid" in data + + async def test_templates_resource_reads(self): + out = await _read("ltspice://templates") + data = json.loads(out) + assert "netlist_templates" in data and "schematic_templates" in data + + +class TestLibraryImplsAreCallable: + """The fix: the *_impl functions must be plain callables (not FunctionTool).""" + + def test_impls_are_callable(self): + assert callable(list_symbols_impl) + assert callable(list_examples_impl) + assert callable(check_installation_impl) + + def test_check_installation_impl_shape(self): + result = check_installation_impl() + assert set(result) >= {"valid", "message", "paths", "lib_exists"}