Extract library_tools + resources; fix 3 dead resources

ltspice://symbols, ltspice://examples, ltspice://status raised TypeError on
read -- they called list_symbols/list_examples/check_installation, which are
@mcp.tool() FunctionTool objects (not callable). Split those three into plain
*_impl functions + thin tool wrappers, and point the resources at the impls.
The other library tools move verbatim.

Adds test_resources.py to read every resource (regression guard). Library tool
outputs verified byte-identical old-vs-new. server.py: 2168 -> 1876 lines.
This commit is contained in:
Ryan Malloy 2026-06-21 20:50:27 -06:00
parent 3a7e6887aa
commit ca6e15e751
4 changed files with 387 additions and 293 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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
# ============================================================================

59
tests/test_resources.py Normal file
View File

@ -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"}