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:
parent
3a7e6887aa
commit
ca6e15e751
254
src/mcltspice/library_tools.py
Normal file
254
src/mcltspice/library_tools.py
Normal 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()
|
||||||
73
src/mcltspice/resources.py
Normal file
73
src/mcltspice/resources.py
Normal 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)
|
||||||
@ -11,7 +11,6 @@ This server provides tools for:
|
|||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -21,23 +20,16 @@ from fastmcp.prompts import Message
|
|||||||
|
|
||||||
# Domain modules register their tools/resources/prompts on the shared mcp
|
# Domain modules register their tools/resources/prompts on the shared mcp
|
||||||
# instance at import time. Imported for that side effect only.
|
# 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 ._app import mcp
|
||||||
from .batch import (
|
from .batch import (
|
||||||
run_monte_carlo,
|
run_monte_carlo,
|
||||||
run_parameter_sweep,
|
run_parameter_sweep,
|
||||||
run_temperature_sweep,
|
run_temperature_sweep,
|
||||||
)
|
)
|
||||||
from .config import (
|
|
||||||
LTSPICE_EXAMPLES,
|
|
||||||
LTSPICE_LIB,
|
|
||||||
validate_installation,
|
|
||||||
)
|
|
||||||
from .diff import diff_schematics as _diff_schematics
|
from .diff import diff_schematics as _diff_schematics
|
||||||
from .drc import run_drc as _run_drc
|
from .drc import run_drc as _run_drc
|
||||||
from .log_parser import parse_log
|
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 .netlist import Netlist
|
||||||
from .noise_analysis import (
|
from .noise_analysis import (
|
||||||
compute_noise_metrics,
|
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
|
# PROMPTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
59
tests/test_resources.py
Normal file
59
tests/test_resources.py
Normal 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"}
|
||||||
Loading…
x
Reference in New Issue
Block a user