diff --git a/powermcp/runner.py b/powermcp/runner.py index 13ba81f..cce8e52 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,9 @@ import importlib.util import runpy import sys +import threading +from contextlib import contextmanager +from collections.abc import Iterator from .registry import Tool, get_tool, install_hint @@ -29,19 +32,35 @@ 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). +_SYS_PATH_LOCK = threading.RLock() + + +@contextmanager +def _temporary_sys_path(*paths: str) -> Iterator[None]: + """Expose paths for one launch and restore the caller's import path. + + Restore the exact caller list contents in place, even if server startup + reorders or mutates it. A finally block guarantees restoration when import + or server startup raises. """ + # sys.path is process-global. Serialize PowerMCP launches so overlapping + # contexts cannot restore snapshots over one another. + with _SYS_PATH_LOCK: + original = sys.path[:] + try: + for path in paths: + if path not in sys.path: + sys.path.insert(0, path) + yield + finally: + # Servers may reorder, remove, or append entries while importing. + # Restore the caller's exact contents in place so references to the + # sys.path list remain valid. + sys.path[:] = original + + +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 +86,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