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