Skip to content

solidworks_mcp.tools.docs_discovery

solidworks_mcp.tools.docs_discovery

Docs discovery tool for SolidWorks COM and VBA command indexing.

This module provides functionality to discover and catalog all available COM objects, methods, and properties for the installed SolidWorks version, as well as VBA library references. Useful for building context for MCP server operations and enabling intelligent tool selection.

Attributes

CompatInputT module-attribute

CompatInputT = TypeVar('CompatInputT', bound=CompatInput)

HAS_WIN32COM module-attribute

HAS_WIN32COM = True

_KNOWN_SW_INTERFACES module-attribute

_KNOWN_SW_INTERFACES: dict[str, dict[str, list[str]]] = {'ISldWorks': {'methods': ['OpenDoc6', 'CloseDoc', 'ActivateDoc3', 'GetActiveObject', 'GetFirstDocument', 'GetDocumentCount', 'NewDocument', 'SendMsgToUser2', 'GetProcessID', 'ExitApp', 'RevisionNumber', 'GetUserTypeLibraryFile', 'CreateNewDocument'], 'properties': ['Visible', 'ActiveDoc', 'FrameState']}, 'IModelDoc2': {'methods': ['ShowNamedView2', 'ViewZoomToFit2', 'SaveBitmapWithVariableSize', 'SaveAs3', 'GetTitle', 'GetPathName', 'GetType', 'GetFeatureCount', 'GetActiveConfiguration', 'Extension', 'SketchManager', 'FeatureManager', 'SelectionManager', 'GetMassProperties', 'Close', 'SetSaveAsFileName', 'ResolveAllLightweightComponents', 'InsertSketch2', 'SketchAddConstraints', 'ClearSelection2'], 'properties': ['ActiveView', 'ActiveLayer']}, 'IPartDoc': {'methods': ['CreateFeatureFromSketch2', 'AddMateReference', 'GetBodies2', 'GetEntityByName', 'InsertSmartComponents', 'Material'], 'properties': []}, 'IAssemblyDoc': {'methods': ['AddComponent4', 'AddComponent5', 'InsertNewPart3', 'LayoutToAssembly', 'ResolveAllLightweightComponents', 'AddMate5', 'GetComponents', 'GetComponentCount'], 'properties': []}, 'IDrawingDoc': {'methods': ['CreateDrawViewFromModelView3', 'InsertModelAnnotations3', 'GenerateViewPalette', 'Get3DDrawingView'], 'properties': []}, 'IFeatureManager': {'methods': ['FeatureExtrusion3', 'FeatureCut3', 'FeatureRevolve2', 'InsertProtrusionBlend4', 'InsertShell', 'InsertFillet3', 'InsertChamfer', 'InsertDraftXpert3', 'FeatureMirror3', 'FeatureCircularPattern2', 'FeatureLinearPattern3', 'InsertBoss', 'FeatureSweep5', 'FeatureLoft3'], 'properties': []}, 'ISketchManager': {'methods': ['InsertSketch', 'CreateLine', 'CreateArc', 'CreateCircle', 'CreateRectangle', 'CreateEllipse', 'CreateSpline', 'CreateCenterLine', 'SketchMirror', 'SketchOffset', 'AddToDB', 'InsertSketch2'], 'properties': ['ActiveSketch']}, 'IModelView': {'methods': ['SaveBitmapWithVariableSize', 'ZoomToFit', 'ZoomToSheet', 'OrientView', 'SetCameraParameters'], 'properties': ['Orientation3', 'Translation3']}, 'IExtensionManager': {'methods': ['SaveAs2', 'SelectByID2', 'MultiSelect2'], 'properties': []}, 'IFeature': {'methods': ['GetNextFeature', 'GetName', 'GetTypeName2', 'SetSuppression2', 'IsSuppressed2', 'GetDefinition'], 'properties': ['Name']}, 'IBody2': {'methods': ['GetFaceCount', 'GetEdgeCount', 'GetVertexCount', 'GetMassProperties', 'GetType'], 'properties': []}, 'ISketchSegment': {'methods': ['GetStartPoint2', 'GetEndPoint2', 'GetType', 'SetEndPoint'], 'properties': []}}

Classes

CompatInput

Bases: BaseModel

Base schema allowing legacy/extra fields used by existing tests.

Attributes:

Name Type Description
model_config Any

The model config value.

DiscoverDocsInput

Bases: CompatInput

Input schema for docs discovery.

Attributes:

Name Type Description
include_vba bool

The include vba value.

output_dir str | None

The output dir value.

year int | None

The year value.

FindRelatedApiInput

Bases: CompatInput

Input schema for a related-members lookup.

LookupApiInterfaceInput

Bases: CompatInput

Input schema for an interface member-list lookup.

LookupApiMethodInput

Bases: CompatInput

Input schema for a single method/property lookup.

SearchApiHelpInput

Bases: CompatInput

Input schema for SolidWorks API help search.

Attributes:

Name Type Description
auto_discover_if_missing bool

The auto discover if missing value.

index_file str | None

The index file value.

max_results int

The max results value.

query str

The query value.

year int | None

The year value.

SolidWorksDocsDiscovery

SolidWorksDocsDiscovery(output_dir: Path | None = None)

Discover and index SolidWorks COM and VBA documentation.

Parameters:

Name Type Description Default
output_dir Path | None

The output dir value. Defaults to None.

None

Attributes:

Name Type Description
output_dir Any

The output dir value.

sw_app Any

The sw app value.

Initialize docs discovery.

Parameters:

Name Type Description Default
output_dir Path | None

The output dir value. Defaults to None.

None
Source code in src/solidworks_mcp/tools/docs_discovery.py
def __init__(self, output_dir: Path | None = None):
    """Initialize docs discovery.

    Args:
        output_dir (Path | None): The output dir value. Defaults to None.
    """
    self.output_dir = output_dir or Path(".generated/docs-index")
    self.output_dir.mkdir(parents=True, exist_ok=True)
    self.sw_app: Any = None
    self.index: dict[str, Any] = {
        "version": "1.0",
        "solidworks_version": None,
        "com_objects": {},
        "vba_references": {},
        "total_methods": 0,
        "total_properties": 0,
    }
Methods:
connect_to_solidworks
connect_to_solidworks() -> bool

Connect to running SolidWorks instance.

Returns:

Name Type Description
bool bool

True if connect to solidworks, otherwise False.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def connect_to_solidworks(self) -> bool:
    """Connect to running SolidWorks instance.

    Returns:
        bool: True if connect to solidworks, otherwise False.
    """
    if not HAS_WIN32COM:
        logger.error("win32com not available; cannot index COM")
        return False

    if platform.system() != "Windows":
        logger.error("COM discovery only available on Windows")
        return False

    try:
        # Prefer GetActiveObject so we attach to a running SolidWorks session.
        # EnsureDispatch is used (instead of Dispatch) so that the type library
        # is loaded and pythoncom ITypeInfo enumeration works correctly.
        try:
            self.sw_app = win32com.client.GetActiveObject("SldWorks.Application")
        except com_error:
            logger.warning(
                "SolidWorks not running; attempting to create new instance"
            )
            self.sw_app = win32com.client.gencache.EnsureDispatch(
                "SldWorks.Application"
            )
        return True
    except com_error as e:
        logger.error(f"Failed to connect to SolidWorks: {e}")
        return False
create_search_summary
create_search_summary() -> dict[str, Any]

Create a summary of indexed documentation for search/reference.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def create_search_summary(self) -> dict[str, Any]:
    """Create a summary of indexed documentation for search/reference.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    summary = {
        "total_com_objects": len(self.index["com_objects"]),
        "total_methods": self.index["total_methods"],
        "total_properties": self.index["total_properties"],
        "solidworks_version": self.index["solidworks_version"],
        "available_vba_libs": [
            lib
            for lib, info in self.index["vba_references"].items()
            if info.get("status") == "available"
        ],
    }
    return summary
discover_all
discover_all() -> dict[str, Any]

Run full discovery of COM and VBA documentation.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def discover_all(self) -> dict[str, Any]:
    """Run full discovery of COM and VBA documentation.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    if not self.connect_to_solidworks():
        logger.error("Cannot proceed with discovery without SolidWorks connection")
        return self.index

    logger.info("Discovering COM objects...")
    self.index["com_objects"] = self.discover_com_objects()

    logger.info("Discovering VBA references...")
    self.index["vba_references"] = self.discover_vba_references()

    return self.index
discover_com_objects
discover_com_objects() -> dict[str, Any]

Discover all COM objects and their methods/properties.

Uses ITypeInfo enumeration via pythoncom for ISldWorks, then supplements with the active document's interface if a model is open. Falls back to the known-interface catalogue when typelib introspection is unavailable.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def discover_com_objects(self) -> dict[str, Any]:
    """Discover all COM objects and their methods/properties.

    Uses ITypeInfo enumeration via pythoncom for ISldWorks, then supplements with the active
    document's interface if a model is open. Falls back to the known-interface catalogue
    when typelib introspection is unavailable.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    if not self.sw_app:
        logger.error("Not connected to SolidWorks")
        return {}

    com_index: dict[str, Any] = {}
    total_m = 0
    total_p = 0

    # ------------------------------------------------------------------ #
    # Primary: ITypeInfo enumeration for ISldWorks
    # ------------------------------------------------------------------ #
    if HAS_WIN32COM:
        try:
            result = _discover_com_via_typeinfo(self.sw_app)
            # _discover_com_via_typeinfo returns (dict, total_m, total_p)
            partial_index, t_m, t_p = result
            com_index.update(partial_index)
            total_m += t_m
            total_p += t_p
            if partial_index:
                logger.info(
                    "[docs_discovery] ITypeInfo: {} ISldWorks methods, {} properties",
                    t_m,
                    t_p,
                )
        except Exception as exc:
            logger.debug("[docs_discovery] ITypeInfo path failed: {}", exc)

    # ------------------------------------------------------------------ #
    # Supplement: active document (IModelDoc2) if a model is open
    # ------------------------------------------------------------------ #
    try:
        active_doc = self.sw_app.ActiveDoc
        if active_doc is not None and HAS_WIN32COM:
            doc_typeinfo = active_doc._oleobj_.GetTypeInfo()
            doc_methods, doc_props = _enumerate_typeinfo_members(doc_typeinfo)
            if doc_methods or doc_props:
                com_index["IModelDoc2_active"] = {
                    "methods": doc_methods,
                    "properties": doc_props,
                    "method_count": len(doc_methods),
                    "property_count": len(doc_props),
                }
                total_m += len(doc_methods)
                total_p += len(doc_props)
                logger.info(
                    "[docs_discovery] Active doc typeinfo: {} methods, {} properties",
                    len(doc_methods),
                    len(doc_props),
                )
    except Exception:
        pass

    # ------------------------------------------------------------------ #
    # Fallback: merge known-interface catalogue for any gaps
    # ------------------------------------------------------------------ #
    for iface_name, members in _KNOWN_SW_INTERFACES.items():
        if iface_name in com_index:
            continue  # already populated via typeinfo
        com_index[iface_name] = {
            "methods": members["methods"],
            "properties": members["properties"],
            "method_count": len(members["methods"]),
            "property_count": len(members["properties"]),
        }
        total_m += len(members["methods"])
        total_p += len(members["properties"])

    try:
        self.index["total_methods"] += total_m
        self.index["total_properties"] += total_p
    except TypeError:
        pass  # accumulator corrupted (e.g. set to a string by tests); tolerate

    # Get SolidWorks version
    try:
        self.index["solidworks_version"] = self.sw_app.RevisionNumber()
    except Exception:
        pass

    return com_index
discover_vba_references
discover_vba_references() -> dict[str, Any]

Discover VBA/TypeLib references via the Windows Registry.

Scans HKEY_CLASSES_ROOT\TypeLib for SolidWorks and common Office/VBA type libraries.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def discover_vba_references(self) -> dict[str, Any]:
    """Discover VBA/TypeLib references via the Windows Registry.

    Scans HKEY_CLASSES_ROOT\\TypeLib for SolidWorks and common Office/VBA type libraries.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    refs = _discover_vba_references_via_registry()
    if refs:
        logger.info(
            "[docs_discovery] Registry TypeLib scan found {} entries", len(refs)
        )
    else:
        logger.debug(
            "[docs_discovery] Registry TypeLib scan found no matching entries"
        )
    return refs
save_index
save_index(filename: str = 'solidworks_docs_index.json') -> Path | None

Save discovered documentation to JSON file.

Parameters:

Name Type Description Default
filename str

The filename value. Defaults to "solidworks_docs_index.json".

'solidworks_docs_index.json'

Returns:

Type Description
Path | None

Path | None: The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def save_index(self, filename: str = "solidworks_docs_index.json") -> Path | None:
    """Save discovered documentation to JSON file.

    Args:
        filename (str): The filename value. Defaults to "solidworks_docs_index.json".

    Returns:
        Path | None: The result produced by the operation.
    """
    try:
        output_path = self.output_dir / filename
        with open(output_path, "w", encoding="utf-8") as f:
            json.dump(self.index, f, indent=2)
        logger.info(f"Docs index saved to {output_path}")
        return output_path
    except Exception as e:
        logger.error(f"Failed to save docs index: {e}")
        return None

Functions:

_detect_installed_solidworks_year

_detect_installed_solidworks_year() -> int | None

Detect the latest installed SolidWorks year from the public samples path.

Returns:

Type Description
int | None

int | None: The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _detect_installed_solidworks_year() -> int | None:
    """Detect the latest installed SolidWorks year from the public samples path.

    Returns:
        int | None: The result produced by the operation.
    """
    root = Path(r"C:\Users\Public\Documents\SOLIDWORKS")
    if not root.exists():
        return None

    years: list[int] = []
    try:
        for child in root.iterdir():
            if not child.is_dir():
                continue
            year = _extract_year(child.name)
            if year is not None:
                years.append(year)
    except OSError:
        # Path can be inaccessible or missing on CI runners and non-Windows hosts.
        return None

    if not years:
        return None
    return max(years)

_discover_com_via_typeinfo

_discover_com_via_typeinfo(sw_app: Any) -> tuple[dict[str, Any], int, int]

Enumerate ISldWorks methods/properties via ITypeInfo.

Returns a COM index dict keyed by interface name, with the same shape as the rest of the index (methods, properties, method_count, property_count).

Parameters:

Name Type Description Default
sw_app Any

The sw app value.

required

Returns:

Type Description
tuple[dict[str, Any], int, int]

tuple[dict[str, Any], int, int]: com_index, total_methods, total_properties.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _discover_com_via_typeinfo(sw_app: Any) -> tuple[dict[str, Any], int, int]:
    """Enumerate ISldWorks methods/properties via ITypeInfo.

    Returns a COM index dict keyed by interface name, with the same shape as the rest of the
    index (``methods``, ``properties``, ``method_count``, ``property_count``).

    Args:
        sw_app (Any): The sw app value.

    Returns:
        tuple[dict[str, Any], int, int]: com_index, total_methods, total_properties.
    """
    if not HAS_WIN32COM:
        return {}, 0, 0

    com_index: dict[str, Any] = {}
    total_methods = 0
    total_properties = 0

    try:
        # GetTypeInfo returns ITypeInfo for the IDispatch interface of the object.
        typeinfo = sw_app._oleobj_.GetTypeInfo()
        methods, properties = _enumerate_typeinfo_members(typeinfo)

        # Walk implemented interfaces to catch inherited members.
        try:
            typeattr = typeinfo.GetTypeAttr()
            for impl_idx in range(typeattr.cImplTypes):
                try:
                    ref_type = typeinfo.GetRefTypeOfImplType(impl_idx)
                    ref_typeinfo = typeinfo.GetRefTypeInfo(ref_type)
                    extra_m, extra_p = _enumerate_typeinfo_members(ref_typeinfo)
                    for m in extra_m:
                        if m not in methods:
                            methods.append(m)
                    for p in extra_p:
                        if p not in properties:
                            properties.append(p)
                except Exception:
                    continue
        except Exception:
            pass

        methods = sorted(set(methods))
        properties = sorted(set(properties))

        if methods or properties:
            com_index["ISldWorks"] = {
                "methods": methods,
                "properties": properties,
                "method_count": len(methods),
                "property_count": len(properties),
            }
            total_methods += len(methods)
            total_properties += len(properties)

    except Exception as exc:
        logger.debug(
            "[docs_discovery] ITypeInfo enumeration unavailable; using built-in interface catalog fallback: {}",
            exc,
        )

    return com_index, total_methods, total_properties

_discover_vba_references_via_registry

_discover_vba_references_via_registry() -> dict[str, Any]

Enumerate VBA/TypeLib references via the Windows Registry.

Scans HKEY_CLASSES_ROOT\TypeLib for entries whose name or GUID matches SolidWorks or common Office/VBA libraries.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _discover_vba_references_via_registry() -> dict[str, Any]:
    """Enumerate VBA/TypeLib references via the Windows Registry.

    Scans HKEY_CLASSES_ROOT\\TypeLib for entries whose name or GUID matches SolidWorks or
    common Office/VBA libraries.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    vba_refs: dict[str, Any] = {}

    # Keywords to look for in type lib names
    interesting_keywords = [
        "solidworks",
        "sldworks",
        "vba",
        "visual basic",
        "stdole",
        "office",
        "microsoft office",
    ]

    if platform.system() != "Windows":
        return vba_refs

    try:
        import winreg

        typelib_key = winreg.OpenKey(
            winreg.HKEY_CLASSES_ROOT, "TypeLib", access=winreg.KEY_READ
        )
        idx = 0
        while True:
            try:
                guid = winreg.EnumKey(typelib_key, idx)
                idx += 1
            except OSError:
                break
            try:
                guid_key = winreg.OpenKey(typelib_key, guid, access=winreg.KEY_READ)
                ver_idx = 0
                while True:
                    try:
                        version = winreg.EnumKey(guid_key, ver_idx)
                        ver_idx += 1
                    except OSError:
                        break
                    try:
                        ver_key = winreg.OpenKey(
                            guid_key, version, access=winreg.KEY_READ
                        )
                        try:
                            name, _ = winreg.QueryValueEx(ver_key, "")
                        except OSError:
                            name = ""
                        winreg.CloseKey(ver_key)
                        if name and any(
                            kw in name.lower() for kw in interesting_keywords
                        ):
                            key = f"{name} ({guid} v{version})"
                            if key not in vba_refs:
                                vba_refs[key] = {
                                    "description": name,
                                    "guid": guid,
                                    "version": version,
                                    "status": "available",
                                }
                    except Exception:
                        continue
                winreg.CloseKey(guid_key)
            except Exception:
                continue
        winreg.CloseKey(typelib_key)
    except Exception as exc:
        logger.debug("[docs_discovery] Registry TypeLib scan failed: {}", exc)

    return vba_refs

_enumerate_typeinfo_members

_enumerate_typeinfo_members(typeinfo: Any) -> tuple[list[str], list[str]]

Enumerate methods and properties from a COM ITypeInfo object.

Uses raw ITypeInfo via pythoncom to walk the function table, which works reliably for both early-bound and late-bound COM objects.

Returns (methods, properties) as sorted de-duped lists.

Parameters:

Name Type Description Default
typeinfo Any

The typeinfo value.

required

Returns:

Type Description
tuple[list[str], list[str]]

tuple[list[str], list[str]]: A tuple containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _enumerate_typeinfo_members(typeinfo: Any) -> tuple[list[str], list[str]]:
    """Enumerate methods and properties from a COM ITypeInfo object.

    Uses raw ITypeInfo via pythoncom to walk the function table, which works reliably for
    both early-bound and late-bound COM objects.

    Returns (methods, properties) as sorted de-duped lists.

    Args:
        typeinfo (Any): The typeinfo value.

    Returns:
        tuple[list[str], list[str]]: A tuple containing the resulting values.
    """
    methods: list[str] = []
    properties: list[str] = []
    seen: set[str] = set()

    try:
        typeattr = typeinfo.GetTypeAttr()
        for i in range(typeattr.cFuncs):
            try:
                funcdesc = typeinfo.GetFuncDesc(i)
                names = typeinfo.GetNames(funcdesc.memid)
                name = names[0] if names else None
                if not name or name.startswith("_"):
                    continue
                if name in seen:
                    continue
                seen.add(name)
                # invkind: 1=FUNC, 2=PROPERTYGET, 4=PROPERTYPUT, 8=PROPERTYPUTREF
                if funcdesc.invkind == 1:
                    methods.append(name)
                else:
                    properties.append(name)
            except Exception:
                continue
    except Exception:
        pass

    return sorted(methods), sorted(properties)

_extract_year

_extract_year(value: str | None) -> int | None

Extract a 4-digit year from any string.

Parameters:

Name Type Description Default
value str | None

The value value.

required

Returns:

Type Description
int | None

int | None: The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _extract_year(value: str | None) -> int | None:
    """Extract a 4-digit year from any string.

    Args:
        value (str | None): The value value.

    Returns:
        int | None: The result produced by the operation.
    """
    if not value:
        return None
    match = re.search(r"\b(20\d{2})\b", value)
    if not match:
        return None
    try:
        return int(match.group(1))
    except ValueError:
        return None

_fallback_help_for_query

_fallback_help_for_query(query: str) -> dict[str, Any]

Provide coherent fallback help when no docs index is available.

Parameters:

Name Type Description Default
query str

Query text used for the operation.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _fallback_help_for_query(query: str) -> dict[str, Any]:
    """Provide coherent fallback help when no docs index is available.

    Args:
        query (str): Query text used for the operation.

    Returns:
        dict[str, Any]: A dictionary containing the resulting values.
    """
    q = query.lower()

    if any(word in q for word in ("revolve", "lathe", "turned")):
        return {
            "suggested_tools": [
                "create_part",
                "create_sketch",
                "add_centerline",
                "add_line",
                "add_arc",
                "exit_sketch",
                "create_revolve",
            ],
            "next_steps": [
                "Create a profile sketch and a centerline in the same sketch.",
                "Exit sketch mode before create_revolve.",
                "If real COM returns parameter mismatch, use generate_vba_revolve as fallback guidance.",
            ],
        }

    if any(word in q for word in ("extrude", "boss", "cut")):
        return {
            "suggested_tools": [
                "create_part",
                "create_sketch",
                "add_rectangle",
                "add_circle",
                "exit_sketch",
                "create_extrusion",
            ],
            "next_steps": [
                "Ensure sketch profile is closed before create_extrusion.",
                "Use direction='cut' for removal operations.",
            ],
        }

    return {
        "suggested_tools": [
            "discover_solidworks_docs",
            "open_model",
            "get_file_properties",
            "save_as",
        ],
        "next_steps": [
            "Run discover_solidworks_docs to index the local COM API surface.",
            "Search again with a narrower API term like a method or interface name.",
        ],
    }

_find_index_file

_find_index_file(year: int | None, explicit_index_file: str | None) -> Path | None

Find the most appropriate index file for a requested year.

Parameters:

Name Type Description Default
year int | None

The year value.

required
explicit_index_file str | None

The explicit index file value.

required

Returns:

Type Description
Path | None

Path | None: The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _find_index_file(year: int | None, explicit_index_file: str | None) -> Path | None:
    """Find the most appropriate index file for a requested year.

    Args:
        year (int | None): The year value.
        explicit_index_file (str | None): The explicit index file value.

    Returns:
        Path | None: The result produced by the operation.
    """
    if explicit_index_file:
        explicit = Path(explicit_index_file)
        if explicit.exists():
            return explicit

    names = ["solidworks_docs_index.json"]
    if year:
        names.insert(0, f"solidworks_docs_index_{year}.json")

    search_dirs = [
        Path(".generated/docs-index"),
        Path("tests/.generated/solidworks_integration/docs-index"),
        Path("tests/.generated/docs-index"),
    ]

    for directory in search_dirs:
        for name in names:
            candidate = directory / name
            if candidate.exists():
                return candidate

    return None
_find_related_api_members(index: dict[str, Any], name: str, max_results: int) -> dict[str, Any]

Given an indexed interface or member name, return related members.

  • An interface name -> its own members.
  • A member name -> the other members of every interface that also exposes it (siblings commonly used together), plus name-substring matches elsewhere in the index.
Source code in src/solidworks_mcp/tools/docs_discovery.py
def _find_related_api_members(
    index: dict[str, Any], name: str, max_results: int
) -> dict[str, Any]:
    """Given an indexed interface or member name, return related members.

    - An interface name -> its own members.
    - A member name -> the other members of every interface that also exposes
      it (siblings commonly used together), plus name-substring matches
      elsewhere in the index.
    """
    com_objects = index.get("com_objects", {})
    related: list[dict[str, Any]] = []
    seen: set[tuple[str, str]] = set()

    def _add(obj_name: str, member: str, member_type: str, reason: str) -> None:
        key = (obj_name, str(member))
        if key in seen or str(member).lower() == name.lower():
            return
        seen.add(key)
        related.append(
            {
                "object": obj_name,
                "member": str(member),
                "member_type": member_type,
                "reason": reason,
            }
        )

    resolved = _resolve_interface(index, name)
    if resolved is not None:
        obj_name, obj_data = resolved
        for m in obj_data.get("methods", []):
            _add(obj_name, m, "method", "same interface")
        for p in obj_data.get("properties", []):
            _add(obj_name, p, "property", "same interface")
        return {"found": True, "query": name, "resolved_as": "interface", "related": related[:max_results]}

    lowered = name.lower()
    matched_member = False
    for obj_name, obj_data in com_objects.items():
        members = [(m, "method") for m in obj_data.get("methods", [])] + [
            (p, "property") for p in obj_data.get("properties", [])
        ]
        names_lower = {str(m).lower() for m, _ in members}
        if lowered in names_lower:
            matched_member = True
            for member, member_type in members:
                _add(obj_name, member, member_type, f"sibling on {obj_name}")
        else:
            for member, member_type in members:
                if lowered in str(member).lower():
                    _add(obj_name, member, member_type, "name match")

    return {
        "found": matched_member or bool(related),
        "query": name,
        "resolved_as": "member" if matched_member else "name-match",
        "related": related[:max_results],
    }

_index_is_stale

_index_is_stale(index_file: Path | None, max_age_days: int) -> bool

True when an existing index file is older than max_age_days.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _index_is_stale(index_file: Path | None, max_age_days: int) -> bool:
    """True when an existing index file is older than ``max_age_days``."""
    if index_file is None:
        return False
    try:
        if not index_file.exists():
            return False
        import time

        age_days = (time.time() - index_file.stat().st_mtime) / 86400.0
        return age_days > max_age_days
    except OSError:
        return False

_load_index_file

_load_index_file(index_file: Path) -> dict[str, Any] | None

Load docs index JSON from disk.

Parameters:

Name Type Description Default
index_file Path

The index file value.

required

Returns:

Type Description
dict[str, Any] | None

dict[str, Any] | None: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _load_index_file(index_file: Path) -> dict[str, Any] | None:
    """Load docs index JSON from disk.

    Args:
        index_file (Path): The index file value.

    Returns:
        dict[str, Any] | None: A dictionary containing the resulting values.
    """
    try:
        if not index_file.exists() or not index_file.is_file():
            return None
        with index_file.open("r", encoding="utf-8") as handle:
            data = json.load(handle)
        if isinstance(data, dict):
            return data
        return None
    except Exception:
        return None

_lookup_interface

_lookup_interface(index: dict[str, Any], interface: str) -> dict[str, Any]

Return every indexed member of an interface, or a no-match marker.

The index stores member names only (no signatures), so this returns the full method/property lists for the interface and nothing more.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _lookup_interface(index: dict[str, Any], interface: str) -> dict[str, Any]:
    """Return every indexed member of an interface, or a no-match marker.

    The index stores member *names* only (no signatures), so this returns the
    full method/property lists for the interface and nothing more.
    """
    resolved = _resolve_interface(index, interface)
    if resolved is None:
        return {"found": False, "interface": interface}
    obj_name, obj_data = resolved
    methods = [str(m) for m in obj_data.get("methods", [])]
    properties = [str(p) for p in obj_data.get("properties", [])]
    return {
        "found": True,
        "interface": obj_name,
        "methods": methods,
        "properties": properties,
        "method_count": len(methods),
        "property_count": len(properties),
    }

_lookup_method

_lookup_method(index: dict[str, Any], interface: str, method: str) -> dict[str, Any]

Confirm a method/property is indexed on an interface.

The index has no signatures, so parameters / return_type are always reported as not indexed; the useful output is confirmation the member exists on that interface plus its sibling members for context.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _lookup_method(index: dict[str, Any], interface: str, method: str) -> dict[str, Any]:
    """Confirm a method/property is indexed on an interface.

    The index has no signatures, so ``parameters`` / ``return_type`` are always
    reported as not indexed; the useful output is confirmation the member
    exists on that interface plus its sibling members for context.
    """
    resolved = _resolve_interface(index, interface)
    if resolved is None:
        return {"found": False, "interface": interface, "method": method}
    obj_name, obj_data = resolved
    lowered = method.lower()
    for member_type, key in (("method", "methods"), ("property", "properties")):
        for member in obj_data.get(key, []):
            if str(member).lower() == lowered:
                return {
                    "found": True,
                    "interface": obj_name,
                    "member": str(member),
                    "member_type": member_type,
                    "parameters": None,
                    "return_type": None,
                    "signature_indexed": False,
                    "note": (
                        "The local docs index stores member names only. Run "
                        "search_solidworks_api_help or consult help.solidworks.com "
                        "for the full signature."
                    ),
                }
    return {
        "found": False,
        "interface": obj_name,
        "method": method,
        "note": f"{method!r} is not an indexed member of {obj_name}.",
    }

_maybe_refresh_index

_maybe_refresh_index(index: dict[str, Any] | None, index_file: Path | None, year: int | None, config: Any) -> tuple[dict[str, Any] | None, Path | None]

Rebuild the docs index if an existing one is stale and the config allows.

Only acts on an index file that exists but is older than docs_index_max_age_days — a missing index is left to the caller's auto_discover_if_missing opt-in. Returns the (possibly refreshed) (index, index_file). A no-op without win32com / SolidWorks, or when config.docs_index_auto_refresh is off; any failure leaves the original index untouched.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _maybe_refresh_index(
    index: dict[str, Any] | None,
    index_file: Path | None,
    year: int | None,
    config: Any,
) -> tuple[dict[str, Any] | None, Path | None]:
    """Rebuild the docs index if an existing one is stale and the config allows.

    Only acts on an index file that exists but is older than
    ``docs_index_max_age_days`` — a *missing* index is left to the caller's
    ``auto_discover_if_missing`` opt-in. Returns the (possibly refreshed)
    ``(index, index_file)``. A no-op without win32com / SolidWorks, or when
    ``config.docs_index_auto_refresh`` is off; any failure leaves the original
    index untouched.
    """
    if not getattr(config, "docs_index_auto_refresh", False):
        return index, index_file
    max_age = int(getattr(config, "docs_index_max_age_days", 21) or 21)
    if not _index_is_stale(index_file, max_age):
        return index, index_file
    if not (HAS_WIN32COM and platform.system() == "Windows"):
        return index, index_file
    try:
        logger.info(
            "Docs index missing or older than {} days; rebuilding via "
            "discover_solidworks_docs",
            max_age,
        )
        discovery = SolidWorksDocsDiscovery()
        fresh = discovery.discover_all()
        filename = (
            f"solidworks_docs_index_{year}.json"
            if year
            else "solidworks_docs_index.json"
        )
        fresh_file = discovery.save_index(filename=filename)
        return fresh, fresh_file or index_file
    except Exception as exc:  # noqa: BLE001 - keep serving the stale index
        logger.warning("Docs index auto-refresh failed, using existing index: {}", exc)
        return index, index_file

_normalize_input

_normalize_input(input_data: Any, model_type: type[CompatInputT]) -> CompatInputT

Normalize dict/model payloads for direct tool invocation paths.

Parameters:

Name Type Description Default
input_data Any

The input data value.

required
model_type type[CompatInputT]

The model type value.

required

Returns:

Name Type Description
CompatInputT CompatInputT

The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _normalize_input(input_data: Any, model_type: type[CompatInputT]) -> CompatInputT:
    """Normalize dict/model payloads for direct tool invocation paths.

    Args:
        input_data (Any): The input data value.
        model_type (type[CompatInputT]): The model type value.

    Returns:
        CompatInputT: The result produced by the operation.
    """
    if input_data is None:
        return model_type()
    if isinstance(input_data, model_type):
        return input_data
    if isinstance(input_data, dict):
        return model_type.model_validate(input_data)
    if hasattr(input_data, "model_dump"):
        return model_type.model_validate(input_data.model_dump())
    return model_type.model_validate(input_data)

_resolve_interface

_resolve_interface(index: dict[str, Any], name: str) -> tuple[str, dict[str, Any]] | None

Case-insensitively resolve an interface name to its (canonical_name, data).

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _resolve_interface(index: dict[str, Any], name: str) -> tuple[str, dict[str, Any]] | None:
    """Case-insensitively resolve an interface name to its (canonical_name, data)."""
    com_objects = index.get("com_objects", {})
    if name in com_objects:
        return name, com_objects[name]
    lowered = name.lower()
    for obj_name, obj_data in com_objects.items():
        if obj_name.lower() == lowered:
            return obj_name, obj_data
    return None

_resolve_solidworks_year

_resolve_solidworks_year(requested_year: int | None, config: Any) -> int | None

Resolve SolidWorks year from explicit request, config, then local installation.

Parameters:

Name Type Description Default
requested_year int | None

The requested year value.

required
config Any

Configuration values for the operation.

required

Returns:

Type Description
int | None

int | None: The result produced by the operation.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _resolve_solidworks_year(requested_year: int | None, config: Any) -> int | None:
    """Resolve SolidWorks year from explicit request, config, then local installation.

    Args:
        requested_year (int | None): The requested year value.
        config (Any): Configuration values for the operation.

    Returns:
        int | None: The result produced by the operation.
    """
    if requested_year:
        return requested_year

    config_year = getattr(config, "solidworks_year", None)
    if isinstance(config_year, int):
        return config_year

    config_path_year = _extract_year(getattr(config, "solidworks_path", None))
    if config_path_year:
        return config_path_year

    return _detect_installed_solidworks_year()

_search_index

_search_index(index: dict[str, Any], query: str, max_results: int) -> list[dict[str, Any]]

Search indexed COM objects/members for a query.

Parameters:

Name Type Description Default
index dict[str, Any]

The index value.

required
query str

Query text used for the operation.

required
max_results int

The max results value.

required

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: A list containing the resulting items.

Source code in src/solidworks_mcp/tools/docs_discovery.py
def _search_index(
    index: dict[str, Any], query: str, max_results: int
) -> list[dict[str, Any]]:
    """Search indexed COM objects/members for a query.

    Args:
        index (dict[str, Any]): The index value.
        query (str): Query text used for the operation.
        max_results (int): The max results value.

    Returns:
        list[dict[str, Any]]: A list containing the resulting items.
    """
    tokens = [t for t in re.split(r"\s+", query.lower().strip()) if t]
    if not tokens:
        return []

    results: list[dict[str, Any]] = []

    def _score(text: str) -> int:
        """Build internal score.

        Args:
            text (str): Input text processed by the operation.

        Returns:
            int: The computed numeric result.
        """
        lower = text.lower()
        score = 0
        for token in tokens:
            if token == lower:
                score += 10
            elif token in lower:
                score += 3
        return score

    for obj_name, obj_data in index.get("com_objects", {}).items():
        obj_score = _score(obj_name)

        for method in obj_data.get("methods", []):
            score = obj_score + _score(str(method))
            if score > 0:
                results.append(
                    {
                        "object": obj_name,
                        "member": method,
                        "member_type": "method",
                        "score": score,
                    }
                )

        for prop in obj_data.get("properties", []):
            score = obj_score + _score(str(prop))
            if score > 0:
                results.append(
                    {
                        "object": obj_name,
                        "member": prop,
                        "member_type": "property",
                        "score": score,
                    }
                )

    for lib_name, lib_data in index.get("vba_references", {}).items():
        lib_text = f"{lib_name} {lib_data.get('description', '')}"
        score = _score(lib_text)
        if score > 0:
            results.append(
                {
                    "object": "VBA Library",
                    "member": lib_name,
                    "member_type": "reference",
                    "status": lib_data.get("status", "unknown"),
                    "score": score,
                }
            )

    results.sort(key=lambda item: item.get("score", 0), reverse=True)
    return results[:max_results]

register_docs_discovery_tools async

register_docs_discovery_tools(mcp: Any, adapter: Any, config: dict[str, Any]) -> int

Register docs discovery tool with FastMCP.

Parameters:

Name Type Description Default
mcp Any

The mcp value.

required
adapter Any

Adapter instance used for the operation.

required
config dict[str, Any]

Configuration values for the operation.

required

Returns:

Name Type Description
int int

The computed numeric result.

Source code in src/solidworks_mcp/tools/docs_discovery.py
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
async def register_docs_discovery_tools(
    mcp: Any, adapter: Any, config: dict[str, Any]
) -> int:
    """Register docs discovery tool with FastMCP.

    Args:
        mcp (Any): The mcp value.
        adapter (Any): Adapter instance used for the operation.
        config (dict[str, Any]): Configuration values for the operation.

    Returns:
        int: The computed numeric result.
    """

    @mcp.tool()  # type: ignore[untyped-decorator]
    async def discover_solidworks_docs(
        input_data: DiscoverDocsInput | None = None,
    ) -> dict[str, Any]:
        """Discover and index SolidWorks COM and VBA documentation.

        Creates a searchable index of all available COM objects, methods, properties, and VBA
        libraries for the installed SolidWorks version. Useful for building context for
        intelligent MCP tool selection and documentation queries.

        Args:
            input_data (DiscoverDocsInput | None): The input data value. Defaults to None.

        Returns:
            dict[str, Any]: A dictionary containing the resulting values.

        Example:
                            ```python
                            result = await discover_solidworks_docs({
                                "output_dir": "docs-index",
                                "include_vba": True
                            })

                            if result["status"] == "success":
                                print(f"Discovered {result['summary']['total_methods']} COM methods")
                                print(f"Output saved to {result['output_file']}")
                            ```

                        Note:
                            - Requires Windows + SolidWorks installed and running
                            - Creates .generated/docs-index directory if not present
                            - Generates solidworks_docs_index.json with full catalog
                            - Index can be used to build context for RAG or tool discovery
        """
        import time

        start_time = time.time()

        if not HAS_WIN32COM:
            return {
                "status": "error",
                "message": "win32com not available; cannot discover COM documentation",
            }

        if platform.system() != "Windows":
            return {
                "status": "error",
                "message": "COM discovery only available on Windows",
            }

        try:
            normalized = _normalize_input(input_data, DiscoverDocsInput)
            output_dir = Path(normalized.output_dir) if normalized.output_dir else None
            year = _resolve_solidworks_year(normalized.year, config)

            discovery = SolidWorksDocsDiscovery(output_dir=output_dir)

            logger.info("Starting SolidWorks documentation discovery...")
            index = discovery.discover_all()

            filename = (
                f"solidworks_docs_index_{year}.json"
                if year
                else "solidworks_docs_index.json"
            )
            output_file = discovery.save_index(filename=filename)

            # Rebuild the FAISS RAG namespace so Gemma can query the API surface.
            rag_indexed = False
            if output_file is not None:
                try:
                    from solidworks_mcp.agents.vector_rag import (
                        build_solidworks_api_docs_index,
                    )

                    rag_idx = build_solidworks_api_docs_index(output_file)
                    rag_idx.save()
                    rag_indexed = True
                    logger.info(
                        "FAISS 'solidworks-api-docs' namespace rebuilt: %d chunks",
                        rag_idx.chunk_count,
                    )
                except ImportError:
                    logger.debug("faiss-cpu not installed; skipping RAG index rebuild")
                except Exception as _rag_exc:
                    logger.warning("RAG rebuild failed: {}", _rag_exc)

            summary = discovery.create_search_summary()

            return {
                "status": "success",
                "message": f"Discovered {summary['total_com_objects']} COM objects, "
                f"{summary['total_methods']} methods, "
                f"{summary['total_properties']} properties",
                "index": index,
                "summary": summary,
                "year": year,
                "output_file": str(output_file) if output_file else None,
                "rag_indexed": rag_indexed,
                "execution_time": time.time() - start_time,
            }

        except Exception as e:
            logger.error(f"Error during docs discovery: {e}")
            return {
                "status": "error",
                "message": f"Discovery failed: {str(e)}",
            }

    @mcp.tool()  # type: ignore[untyped-decorator]
    async def search_solidworks_api_help(
        input_data: SearchApiHelpInput | None = None,
    ) -> dict[str, Any]:
        """Search SolidWorks API help index and return coherent guidance.

        This tool helps when the LLM gets stuck by mapping user intent to discovered COM members
        and practical MCP workflow guidance.

        Args:
            input_data (SearchApiHelpInput | None): The input data value. Defaults to None.

        Returns:
            dict[str, Any]: A dictionary containing the resulting values.
        """

        import time

        start_time = time.time()

        try:
            normalized = _normalize_input(input_data, SearchApiHelpInput)
            year = _resolve_solidworks_year(normalized.year, config)

            index_file = _find_index_file(year, normalized.index_file)
            index = _load_index_file(index_file) if index_file else None

            # Rebuild when stale (config-gated), or when missing and the caller
            # opted in.
            index, index_file = _maybe_refresh_index(index, index_file, year, config)
            if index is None and normalized.auto_discover_if_missing:
                if HAS_WIN32COM and platform.system() == "Windows":
                    discovery = SolidWorksDocsDiscovery()
                    index = discovery.discover_all()
                    filename = (
                        f"solidworks_docs_index_{year}.json"
                        if year
                        else "solidworks_docs_index.json"
                    )
                    index_file = discovery.save_index(filename=filename)

            matches = _search_index(
                index or {}, normalized.query, normalized.max_results
            )
            fallback = _fallback_help_for_query(normalized.query)

            guidance_lines = []
            if matches:
                guidance_lines.append(
                    f"Found {len(matches)} API matches for '{normalized.query}'."
                )
                guidance_lines.append(
                    "Start with the highest-score members and verify with the real adapter path."
                )
            else:
                guidance_lines.append(
                    "No indexed API hits found; using workflow fallback guidance."
                )
                guidance_lines.append(
                    "Consider running discover_solidworks_docs first for this machine/version."
                )

            return {
                "status": "success",
                "message": "SolidWorks API help search completed",
                "query": normalized.query,
                "year": year,
                "source_index_file": str(index_file) if index_file else None,
                "matches": matches,
                "fallback_help": fallback,
                "guidance": " ".join(guidance_lines),
                "execution_time": time.time() - start_time,
            }

        except Exception as e:
            logger.error(f"Error during SolidWorks API help search: {e}")
            return {
                "status": "error",
                "message": f"API help search failed: {str(e)}",
            }

    def _load_index_for_lookup(
        requested_year: int | None, explicit_index_file: str | None
    ) -> tuple[dict[str, Any] | None, Path | None, int | None]:
        """Shared index resolution for the granular lookup tools."""
        resolved_year = _resolve_solidworks_year(requested_year, config)
        index_file = _find_index_file(resolved_year, explicit_index_file)
        index = _load_index_file(index_file) if index_file else None
        index, index_file = _maybe_refresh_index(
            index, index_file, resolved_year, config
        )
        return index, index_file, resolved_year

    @mcp.tool()  # type: ignore[untyped-decorator]
    async def lookup_api_method(
        input_data: LookupApiMethodInput | None = None,
    ) -> dict[str, Any]:
        """Look up one SolidWorks API method or property by interface + name.

        Confirms the member is indexed on that interface and returns its
        sibling members for context. The local index stores names only, so
        full signatures (parameters, return type) are not available here - use
        search_solidworks_api_help or help.solidworks.com for those.
        """
        import time

        start = time.time()
        try:
            normalized = _normalize_input(input_data, LookupApiMethodInput)
            index, index_file, year = _load_index_for_lookup(
                normalized.year, normalized.index_file
            )
            result = _lookup_method(
                index or {}, normalized.interface, normalized.method
            )
            return {
                "status": "success",
                "message": (
                    "Method found"
                    if result.get("found")
                    else "No indexed match for that interface/method"
                ),
                "year": year,
                "source_index_file": str(index_file) if index_file else None,
                "result": result,
                "execution_time": time.time() - start,
            }
        except Exception as e:  # noqa: BLE001
            logger.error(f"Error during lookup_api_method: {e}")
            return {"status": "error", "message": f"lookup_api_method failed: {e}"}

    @mcp.tool()  # type: ignore[untyped-decorator]
    async def lookup_api_interface(
        input_data: LookupApiInterfaceInput | None = None,
    ) -> dict[str, Any]:
        """List every indexed member (methods + properties) of a COM interface."""
        import time

        start = time.time()
        try:
            normalized = _normalize_input(input_data, LookupApiInterfaceInput)
            index, index_file, year = _load_index_for_lookup(
                normalized.year, normalized.index_file
            )
            result = _lookup_interface(index or {}, normalized.interface)
            return {
                "status": "success",
                "message": (
                    f"{result['method_count']} methods, {result['property_count']} properties"
                    if result.get("found")
                    else "Interface not found in the indexed documentation"
                ),
                "year": year,
                "source_index_file": str(index_file) if index_file else None,
                "result": result,
                "execution_time": time.time() - start,
            }
        except Exception as e:  # noqa: BLE001
            logger.error(f"Error during lookup_api_interface: {e}")
            return {
                "status": "error",
                "message": f"lookup_api_interface failed: {e}",
            }

    @mcp.tool()  # type: ignore[untyped-decorator]
    async def find_related_api_members(
        input_data: FindRelatedApiInput | None = None,
    ) -> dict[str, Any]:
        """Find API members related to a known interface or member name.

        For an interface: its own members. For a member: the other members of
        every interface that also exposes it, plus name-substring matches.
        """
        import time

        start = time.time()
        try:
            normalized = _normalize_input(input_data, FindRelatedApiInput)
            index, index_file, year = _load_index_for_lookup(
                normalized.year, normalized.index_file
            )
            result = _find_related_api_members(
                index or {}, normalized.name, normalized.max_results
            )
            return {
                "status": "success",
                "message": (
                    f"{len(result['related'])} related member(s)"
                    if result.get("found")
                    else "No related members found"
                ),
                "year": year,
                "source_index_file": str(index_file) if index_file else None,
                "result": result,
                "execution_time": time.time() - start,
            }
        except Exception as e:  # noqa: BLE001
            logger.error(f"Error during find_related_api_members: {e}")
            return {
                "status": "error",
                "message": f"find_related_api_members failed: {e}",
            }

    # discover_solidworks_docs, search_solidworks_api_help, lookup_api_method,
    # lookup_api_interface, find_related_api_members
    tool_count = 5
    return tool_count