From c6a871bffdf47776f753debbeab7e7e83a6e3add Mon Sep 17 00:00:00 2001 From: Burhan Abdullah <131237388+BurhanAbdullah@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:00:46 +0530 Subject: [PATCH 01/12] fix(runner): restore temporary import paths after launch --- powermcp/runner.py | 82 ++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/powermcp/runner.py b/powermcp/runner.py index 13ba81f3..39dfc2b3 100644 --- a/powermcp/runner.py +++ b/powermcp/runner.py @@ -5,15 +5,15 @@ standalone from a checkout. Three launch styles (declared per tool in the registry): -- ``script``: run the entry .py as ``__main__`` (its own ``mcp.run(...)`` fires), - after putting the server dir and its parent on ``sys.path`` so existing - relative imports (PyPSA's ``sys.path.append`` parent, OpenDSS's - ``from core.server import ...``) resolve exactly as in standalone use. -- ``module``: put the module root on ``sys.path`` and ``runpy.run_module`` the - package's ``__main__`` (PSCAD's ``pscad_mcp.main``, HOPE's ``hope_mcp_server``). -- ``package``: ``runpy.run_module`` a server that ships in its own distribution - and is already importable (powerio's ``powerio.mcp``). Nothing goes on - ``sys.path`` and this repo bundles no copy of that server. +- ``script``: run the entry .py as ``__main__`` after temporarily putting + the server dir on ``sys.path``. +- ``module``: put the module root on ``sys.path`` and run its package. +- ``package``: run a server shipped in its own distribution; no path changes. + +All temporary import-path changes are restored when execution returns or +raises. This matters when PowerMCP is embedded in another Python process; +standalone CLI execution exits shortly afterwards, but tests and host +applications may continue importing modules. """ from __future__ import annotations @@ -21,6 +21,8 @@ import importlib.util import runpy import sys +from contextlib import contextmanager +from collections.abc import Iterator from .registry import Tool, get_tool, install_hint @@ -29,19 +31,33 @@ class LaunchError(RuntimeError): """Raised with an actionable message when a server cannot be launched.""" -def probe_installed(probe: str | None) -> bool: - """True if ``probe`` resolves to a real (importable) module/package. - - Uses find_spec on the FULL dotted name (no module execution). Two cases this - must get right: - - ``surge`` (top-level): the repo's ``surge/`` folder is a PEP 420 namespace - portion (origin None) and must NOT be mistaken for the installed library — - so we require a real ``origin``. - - ``mhi.pscad`` (subpackage of a namespace): ``mhi`` itself is a namespace - package shared by the ``mhi-pscad``/``mhi-psout`` distributions, so probing - the top-level ``mhi`` would wrongly look "missing". Probing the full - ``mhi.pscad`` resolves the real subpackage (which has an origin). +@contextmanager +def _temporary_sys_path(*paths: str) -> Iterator[None]: + """Expose paths for one launch and restore the caller's import path. + + Remove only entries introduced by this context, preserving any existing + caller entries (including their original order). A finally block guarantees + restoration when import or server startup raises. """ + added: list[str] = [] + try: + for path in paths: + if path not in sys.path: + sys.path.insert(0, path) + added.append(path) + yield + finally: + for path in reversed(added): + try: + sys.path.remove(path) + except ValueError: + # A server may have removed its own temporary path. Do not + # mask the original launch exception while restoring state. + pass + + +def probe_installed(probe: str | None) -> bool: + """True if ``probe`` resolves to a real (importable) module/package.""" if not probe: return True try: @@ -67,44 +83,30 @@ def _preflight(tool: Tool) -> None: raise LaunchError( f"{tool.display} requires Windows-only software and cannot run on '{sys.platform}'." ) - # Every server imports the MCP SDK at module scope, so without this the - # failure is a raw ImportError traceback rather than an actionable message. if not probe_installed("mcp"): raise LaunchError( "The MCP SDK is not installed; every server needs it.\n" " Install it with: pip install powermcp" ) - # Only probe pip-provided linchpins. Vendor engines (PSS/E psspy, PSLF) have - # probe=None: they live behind a captured path and report their own - # actionable error from the server's lazy init. if tool.probe and not probe_installed(tool.probe): raise LaunchError( f"{tool.display}: required package '{tool.probe.split('.')[0]}' is not installed.\n" - f" Install it with: {install_hint(tool.extra)}" + f" Install it with: {install_hint(tool.extra)}" ) def _launch_script(tool: Tool) -> None: script = tool.resolve_entry_script() - # Emulate `python