pythonparts-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ """
2
+ Self-contained worker script that executes arbitrary Python against the ALLPLAN
3
+ libraries and returns the captured console output as JSON on stdout.
4
+
5
+ This script is executed by the ALLPLAN-embedded interpreter (not the MCP server's
6
+ interpreter). It must not import anything from the server package.
7
+
8
+ Unlike the Vibe Coding Connector, this runs outside ALLPLAN: there is no document
9
+ and no coordinate input, so only APIs that work without them return meaningful
10
+ results.
11
+
12
+ Protocol (persistent mode):
13
+ 1. Caller writes one JSON init line to stdin:
14
+ {"sys_path": [...], "prg_plugins": "...", "allplan_paths_mock": "...", "etc_pythonparts": "..."}
15
+ Worker sets up the ALLPLAN runtime and imports the interface modules, then
16
+ writes one JSON ack to stdout:
17
+ {"ok": true, "data": {"available": [...], "unavailable": {...}}}
18
+ or {"ok": false, "error": "..."}
19
+ 2. For each snippet the caller writes one JSON line:
20
+ {"code": "print(1 + 1)"}
21
+ Worker writes one JSON result to stdout, then loops.
22
+ 3. Caller closes stdin (EOF) to signal shutdown; the worker exits cleanly.
23
+ """
24
+
25
+ # Running untrusted code and surviving anything it throws is this module's purpose.
26
+ # pylint: disable=broad-except,exec-used,global-statement,import-error
27
+
28
+ from __future__ import annotations
29
+
30
+ import importlib
31
+ import importlib.util
32
+ import io
33
+ import json
34
+ import os
35
+ import sys
36
+ import traceback
37
+ from contextlib import redirect_stderr, redirect_stdout
38
+ from typing import Any
39
+
40
+ _DLL_DIR_HANDLE: Any | None = None
41
+
42
+ # Alias -> module name, mirroring the globals of the Vibe Coding Connector's /execute
43
+ # route so a snippet can be moved between the two tools unchanged.
44
+ _MODULE_ALIASES = {
45
+ "AllplanSettings": "NemAll_Python_AllplanSettings",
46
+ "AllplanArchElements": "NemAll_Python_ArchElements",
47
+ "AllplanBaseElements": "NemAll_Python_BaseElements",
48
+ "AllplanBasisElements": "NemAll_Python_BasisElements",
49
+ "AllplanGeometry": "NemAll_Python_Geometry",
50
+ "AllplanElementAdapter": "NemAll_Python_IFW_ElementAdapter",
51
+ "AllplanIFWInput": "NemAll_Python_IFW_Input",
52
+ "AllplanPalette": "NemAll_Python_Palette",
53
+ "AllplanReinforcement": "NemAll_Python_Reinforcement",
54
+ "AllplanPrecast": "NemAll_Python_Precast",
55
+ "AllplanUtility": "NemAll_Python_Utility",
56
+ }
57
+
58
+ _modules: dict[str, Any] = {}
59
+ _unavailable: dict[str, str] = {}
60
+
61
+ # A runaway snippet such as `while True: print(...)` would otherwise grow the
62
+ # capture buffer until the machine runs out of memory, long before the caller's
63
+ # timeout kills the worker.
64
+ _MAX_OUTPUT_CHARS = 1_000_000
65
+
66
+
67
+ class _BoundedOutput(io.TextIOBase):
68
+ """Text sink that keeps at most `limit` characters and discards the rest."""
69
+
70
+ def __init__(self, limit: int = _MAX_OUTPUT_CHARS) -> None:
71
+ super().__init__()
72
+ self._limit = limit
73
+ self._chunks: list[str] = []
74
+ self._size = 0
75
+ self._truncated = False
76
+
77
+ def writable(self) -> bool:
78
+ return True
79
+
80
+ def write(self, text: str) -> int:
81
+ if self._truncated:
82
+ return len(text)
83
+ remaining = self._limit - self._size
84
+ if len(text) <= remaining:
85
+ self._chunks.append(text)
86
+ self._size += len(text)
87
+ else:
88
+ self._chunks.append(text[:remaining])
89
+ self._chunks.append(
90
+ f"\n... output truncated after {self._limit} characters ...\n"
91
+ )
92
+ self._size = self._limit
93
+ self._truncated = True
94
+ return len(text)
95
+
96
+ def getvalue(self) -> str:
97
+ return "".join(self._chunks)
98
+
99
+
100
+ def _read_line() -> dict | None:
101
+ """Return the next JSON object from stdin, or None on EOF."""
102
+ line = sys.stdin.readline()
103
+ if not line:
104
+ return None
105
+ return json.loads(line)
106
+
107
+
108
+ def _setup_environment(params: dict) -> None:
109
+ """Inject DLL search path and sys.path, then patch AllplanPaths if possible."""
110
+ global _DLL_DIR_HANDLE
111
+
112
+ _DLL_DIR_HANDLE = os.add_dll_directory(params["prg_plugins"])
113
+
114
+ for path in reversed(params["sys_path"]):
115
+ if path not in sys.path:
116
+ sys.path.insert(0, path)
117
+
118
+ # Patch AllplanSettings.AllplanPaths so framework modules that call
119
+ # GetPathOfApplication() at import-time receive the real Prg path rather
120
+ # than the interpreter directory (Prg\Python\).
121
+ try:
122
+ mock_spec = importlib.util.spec_from_file_location(
123
+ "AllplanPathsMock", params["allplan_paths_mock"]
124
+ )
125
+ if mock_spec is None or mock_spec.loader is None:
126
+ raise ImportError("Could not load AllplanPathsMock spec")
127
+ mock_mod = importlib.util.module_from_spec(mock_spec)
128
+ mock_spec.loader.exec_module(mock_mod)
129
+
130
+ import NemAll_Python_AllplanSettings as _AllplanSettings # noqa: PLC0415
131
+ _AllplanSettings.AllplanPaths = mock_mod.AllplanPathsMock(
132
+ etc_path=params["etc_pythonparts"]
133
+ )
134
+ except Exception: # noqa: BLE001 — AllplanPaths mock is optional; real errors surface below
135
+ pass
136
+
137
+
138
+ def _import_modules() -> None:
139
+ """Import every interface module once, recording the ones that refuse to load.
140
+
141
+ Some modules need a running ALLPLAN and cannot be imported standalone; that is
142
+ expected, so one failure must not take down the whole sandbox.
143
+ """
144
+ for alias, module_name in _MODULE_ALIASES.items():
145
+ try:
146
+ _modules[alias] = importlib.import_module(module_name)
147
+ except Exception as exc: # noqa: BLE001 — report, don't abort
148
+ _unavailable[alias] = f"{type(exc).__name__}: {exc}"
149
+
150
+
151
+ def _execute(code: str) -> dict:
152
+ """Run one snippet with a fresh globals dict and return its captured output."""
153
+ output = _BoundedOutput()
154
+ try:
155
+ with redirect_stdout(output), redirect_stderr(output):
156
+ exec(code, dict(_modules)) # noqa: S102 — executing user code is the point
157
+ except BaseException: # noqa: BLE001 — SystemExit from the snippet must not kill the worker
158
+ return {"ok": False, "error": output.getvalue() + traceback.format_exc()}
159
+ return {"ok": True, "data": {"output": output.getvalue()}}
160
+
161
+
162
+ def _emit(obj: dict) -> None:
163
+ """Write one JSON line to the real stdout and flush immediately."""
164
+ sys.stdout.write(json.dumps(obj) + "\n")
165
+ sys.stdout.flush()
166
+
167
+
168
+ def main() -> None:
169
+ # Stage 1: one-time environment setup and module import.
170
+ init_params = _read_line()
171
+ if init_params is None:
172
+ return
173
+ try:
174
+ _setup_environment(init_params)
175
+ _import_modules()
176
+ _emit({
177
+ "ok": True,
178
+ "data": {"available": sorted(_modules), "unavailable": _unavailable},
179
+ })
180
+ except Exception as exc: # noqa: BLE001
181
+ _emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
182
+ return
183
+
184
+ # Stage 2: execution loop — one snippet per line until stdin closes.
185
+ while True:
186
+ query = _read_line()
187
+ if query is None:
188
+ break
189
+ try:
190
+ _emit(_execute(query["code"]))
191
+ except Exception as exc: # noqa: BLE001
192
+ _emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
@@ -0,0 +1,81 @@
1
+ """
2
+ Shared helper for tools that require the Vibe Coding Connector to be running in ALLPLAN.
3
+
4
+ Apply the `@requires_allplan_connector` decorator to any `@mcp.tool` function that talks to the
5
+ connector's HTTP server; it takes care of the health check, the "please start it" elicitation, and
6
+ returning "Tool not available" once the user declines.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any, Callable, Coroutine, TypeVar
16
+
17
+ from fastmcp import Context
18
+
19
+ from ..config import CONNECTOR_BASE_URL
20
+
21
+ _F = TypeVar("_F", bound=Callable[..., Coroutine[Any, Any, dict]])
22
+
23
+ _HEALTH_TIMEOUT_SECONDS = 5.0
24
+
25
+ _START_OPTION = "I started the connector"
26
+ _DECLINE_OPTION = "I don't want to use the connector"
27
+ _MAX_ELICIT_ROUNDS = 5
28
+
29
+ # Set once the user declines; stays set for the life of this process (reset on server restart).
30
+ _connector_declined = False
31
+
32
+
33
+ def check_health(timeout: float = _HEALTH_TIMEOUT_SECONDS) -> bool:
34
+ """Return True if the Vibe Coding Connector responds to GET /health."""
35
+ try:
36
+ with urllib.request.urlopen(f"{CONNECTOR_BASE_URL}/health", timeout=timeout) as response:
37
+ return response.status == 200
38
+ except (urllib.error.URLError, TimeoutError):
39
+ return False
40
+
41
+
42
+ async def ensure_connector_available(ctx: Context) -> bool:
43
+ """Return True once the connector answers /health, prompting the user to start it if needed."""
44
+ global _connector_declined
45
+
46
+ if _connector_declined:
47
+ return False
48
+
49
+ for _ in range(_MAX_ELICIT_ROUNDS):
50
+ if check_health():
51
+ return True
52
+
53
+ try:
54
+ result = await ctx.elicit(
55
+ "ALLPLAN is not reachable. Please start the Vibe Coding Connector in ALLPLAN.",
56
+ response_type=[_START_OPTION, _DECLINE_OPTION],
57
+ )
58
+ except Exception: # noqa: BLE001 — client may not support elicitation
59
+ _connector_declined = True
60
+ return False
61
+
62
+ if result.action != "accept" or result.data == _DECLINE_OPTION:
63
+ _connector_declined = True
64
+ return False
65
+ # Otherwise the user picked _START_OPTION: loop around and re-check health.
66
+
67
+ return False
68
+
69
+
70
+ def requires_allplan_connector(func: _F) -> _F:
71
+ """Gate a tool on the Vibe Coding Connector being available. The tool must take a `ctx: Context` parameter."""
72
+ signature = inspect.signature(func)
73
+
74
+ @functools.wraps(func)
75
+ async def wrapper(*args: Any, **kwargs: Any) -> dict:
76
+ ctx = signature.bind_partial(*args, **kwargs).arguments["ctx"]
77
+ if not await ensure_connector_available(ctx):
78
+ return {"ok": False, "error": "Tool not available"}
79
+ return await func(*args, **kwargs)
80
+
81
+ return wrapper # type: ignore[return-value]
@@ -0,0 +1,75 @@
1
+ """
2
+ Tool: inspect_symbol
3
+
4
+ Inspects a Python symbol in the ALLPLAN runtime using live introspection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from fastmcp import FastMCP
12
+
13
+ from ..config import AllplanConfig
14
+ from ..paths import build_sys_path
15
+ from ..worker import PersistentWorker
16
+ from . import READ_ONLY_TOOL_ANNOTATIONS
17
+
18
+ _INIT_TIMEOUT_SECONDS = 120.0
19
+ _REQUEST_TIMEOUT_SECONDS = 30.0
20
+
21
+
22
+ def _init_params(cfg: AllplanConfig) -> dict:
23
+ return {
24
+ "sys_path": build_sys_path(cfg),
25
+ "prg_plugins": str(cfg.prg / "plugins"),
26
+ "allplan_paths_mock": str(
27
+ cfg.etc / "PythonPartsFramework" / "TestHelper" / "Mock" / "AllplanPathsMock.py"
28
+ ),
29
+ "etc_pythonparts": str(cfg.etc / "PythonPartsFramework") + "\\",
30
+ "stub_dirs": [str(cfg.etc / "PythonPartsFramework" / "InterfaceStubs")],
31
+ }
32
+
33
+
34
+ _worker = PersistentWorker(
35
+ Path(__file__).parent / "_introspect_worker.py",
36
+ _init_params,
37
+ init_timeout=_INIT_TIMEOUT_SECONDS,
38
+ request_timeout=_REQUEST_TIMEOUT_SECONDS,
39
+ )
40
+
41
+
42
+ def register_inspect_symbol_tools(mcp: FastMCP) -> None:
43
+ """Register all symbol-inspection tools on the given FastMCP instance."""
44
+
45
+ @mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
46
+ def inspect_symbol(symbol: str) -> dict:
47
+ """
48
+ Inspect a Python symbol in the ALLPLAN runtime using live introspection.
49
+
50
+ Returns the category, signature, docstring, and public members of the symbol.
51
+ Use this to verify that a symbol exists and to discover its API before
52
+ writing code against it.
53
+
54
+ For native (pybind11) modules the result is enriched from the matching
55
+ ``.pyi`` stub, which yields real signatures, per-overload documentation
56
+ and member signatures that live introspection cannot provide.
57
+
58
+ Args:
59
+ symbol: Dotted Python path to inspect.
60
+ Examples:
61
+ "NemAll_Python_Geometry.Point3D" — a class
62
+ "AllplanBasisElements" — a module
63
+ "NemAll_Python_Geometry.Point3D.X" — a member
64
+
65
+ Returns a dict with:
66
+ ok — True on success, False on failure
67
+ data — {symbol, category, module, docstring, signature, overloads,
68
+ members, magic_methods, stub_file}
69
+ docstrings are returned as a list of lines (split on \n);
70
+ overloads is a list of {signature, docstring} and is empty
71
+ for non-overloaded symbols; members carry signatures when
72
+ they come from a stub
73
+ error — present only when ok is False
74
+ """
75
+ return _worker.request({"symbol": symbol})
@@ -0,0 +1,85 @@
1
+ """
2
+ Tool: run_snippet_in_allplan
3
+
4
+ Executes a Python code snippet inside ALLPLAN's own embedded interpreter via
5
+ the Vibe Coding Connector PythonPart's HTTP server, and returns its stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from fastmcp import Context, FastMCP
14
+
15
+ from ..config import CONNECTOR_BASE_URL
16
+ from . import MUTATING_TOOL_ANNOTATIONS
17
+ from .connector import requires_allplan_connector
18
+
19
+ _EXECUTE_TIMEOUT_SECONDS = 60.0
20
+
21
+
22
+ def _call_execute(code: str, timeout: float) -> tuple[int, str]:
23
+ """POST the snippet to /execute and return (status_code, response_body)."""
24
+ request = urllib.request.Request(
25
+ f"{CONNECTOR_BASE_URL}/execute",
26
+ data=code.encode("utf-8"),
27
+ method="POST",
28
+ headers={"Content-Type": "text/plain; charset=utf-8"},
29
+ )
30
+ try:
31
+ with urllib.request.urlopen(request, timeout=timeout) as response:
32
+ return response.status, response.read().decode("utf-8", errors="replace")
33
+ except urllib.error.HTTPError as exc:
34
+ # The connector returns 500 with the captured output + traceback in the body.
35
+ return exc.code, exc.read().decode("utf-8", errors="replace")
36
+
37
+
38
+ def register_run_snippet_in_allplan_tools(mcp: FastMCP) -> None:
39
+ @mcp.tool(annotations=MUTATING_TOOL_ANNOTATIONS)
40
+ @requires_allplan_connector
41
+ async def run_snippet_in_allplan(code: str, ctx: Context) -> dict:
42
+ """
43
+ Run a Python code snippet inside ALLPLAN's embedded interpreter and
44
+ return its stdout.
45
+
46
+ This runs in the user's live ALLPLAN session, so the snippet can read and
47
+ modify the open drawing and project data. Use it only when the snippet
48
+ genuinely needs `document`, `coord_input`, the project, or user settings.
49
+ For everything else — geometry math, checking an API's behaviour or a
50
+ return type — use `run_snippet_in_sandbox`, which is isolated from the
51
+ user's data.
52
+
53
+ Requires the Vibe Coding Connector PythonPart to be running in ALLPLAN.
54
+ If it isn't reachable, the user is prompted to start it; declining
55
+ makes this tool return "Tool not available" for the rest of the
56
+ session, until the MCP server is restarted.
57
+
58
+ The snippet executes with the following already available as globals
59
+ (no need to import them):
60
+ AllplanSettints, AllplanArchElements, AllplanBaseElements,
61
+ AllplanBasisElements, AllplanGeometry, AllplanElementAdapter,
62
+ AllplanIFWInput, AllplanPalette, AllplanReinforcement,
63
+ AllplanPrecast, AllplanUtility — pre-imported ALLPLAN modules
64
+ document — the active DocumentAdapter
65
+ coord_input — the active CoordinateInput
66
+
67
+ Only `print()` output is returned; the snippet's return value is ignored.
68
+
69
+ Args:
70
+ code: Python source code to execute, as plain text.
71
+
72
+ Returns a dict with:
73
+ ok — True on success, False on failure
74
+ data — {"output": str} on success
75
+ error — present only when ok is False ("Tool not available",
76
+ or the snippet's traceback if it raised)
77
+ """
78
+ try:
79
+ status_code, body = _call_execute(code, _EXECUTE_TIMEOUT_SECONDS)
80
+ except (urllib.error.URLError, TimeoutError) as exc:
81
+ return {"ok": False, "error": f"Failed to reach the Vibe Coding Connector: {exc}"}
82
+
83
+ if status_code == 200:
84
+ return {"ok": True, "data": {"output": body}}
85
+ return {"ok": False, "error": body}
@@ -0,0 +1,91 @@
1
+ """
2
+ Tool: run_snippet_in_sandbox
3
+
4
+ Executes a Python code snippet against the ALLPLAN libraries in a separate,
5
+ document-less process, so nothing can touch the user's live ALLPLAN session.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from fastmcp import FastMCP
13
+
14
+ from ..config import AllplanConfig
15
+ from ..paths import build_sys_path
16
+ from ..worker import PersistentWorker
17
+ from . import MUTATING_TOOL_ANNOTATIONS
18
+
19
+ # The first call pays for loading the ALLPLAN DLLs; later calls reuse the process.
20
+ _INIT_TIMEOUT_SECONDS = 120.0
21
+ _REQUEST_TIMEOUT_SECONDS = 60.0
22
+
23
+
24
+ def _init_params(cfg: AllplanConfig) -> dict:
25
+ return {
26
+ "sys_path": build_sys_path(cfg),
27
+ "prg_plugins": str(cfg.prg / "plugins"),
28
+ "allplan_paths_mock": str(
29
+ cfg.etc / "PythonPartsFramework" / "TestHelper" / "Mock" / "AllplanPathsMock.py"
30
+ ),
31
+ "etc_pythonparts": str(cfg.etc / "PythonPartsFramework") + "\\",
32
+ }
33
+
34
+
35
+ _worker = PersistentWorker(
36
+ Path(__file__).parent / "_sandbox_worker.py",
37
+ _init_params,
38
+ init_timeout=_INIT_TIMEOUT_SECONDS,
39
+ request_timeout=_REQUEST_TIMEOUT_SECONDS,
40
+ )
41
+
42
+
43
+ def register_run_snippet_in_sandbox_tools(mcp: FastMCP) -> None:
44
+ @mcp.tool(annotations=MUTATING_TOOL_ANNOTATIONS)
45
+ def run_snippet_in_sandbox(code: str) -> dict:
46
+ """
47
+ Run a Python code snippet against the ALLPLAN libraries in a separate,
48
+ isolated process and return its stdout. It cannot touch the user's
49
+ drawing or project data, and ALLPLAN does not need to be running.
50
+
51
+ Use it to verify geometry math and API behaviour, for example to check a
52
+ `NemAll_Python_Geometry` result or a class signature, before writing a
53
+ full PythonPart.
54
+
55
+ There is no document and no coordinate input, so `document` and
56
+ `coord_input` are NOT available here. Any call that needs the open
57
+ drawing, the project, or user settings will fail or return a
58
+ meaningless value.
59
+
60
+ The snippet must not call anything that shows a user interface, such as
61
+ `AllplanUtility.ShowMessageBox` or a file dialog. There is no ALLPLAN
62
+ window to host it, so the call never returns and the snippet is lost to
63
+ the timeout.
64
+
65
+ The snippet executes with the following already available as globals
66
+ (no need to import them):
67
+ AllplanSettints, AllplanArchElements, AllplanBaseElements,
68
+ AllplanBasisElements, AllplanGeometry, AllplanElementAdapter,
69
+ AllplanIFWInput, AllplanPalette, AllplanReinforcement,
70
+ AllplanPrecast, AllplanUtility — pre-imported ALLPLAN modules
71
+
72
+ Only `print()` output is returned; the snippet's return value is ignored.
73
+ Globals do not persist between calls, so each snippet must be
74
+ self-contained. The snippet must terminate — it is abandoned after
75
+ 60 seconds.
76
+
77
+ Args:
78
+ code: Python source code to execute, as plain text.
79
+
80
+ Returns a dict with:
81
+ ok — True on success, False on failure
82
+ data — {"output": str} on success
83
+ error — present only when ok is False (the snippet's traceback,
84
+ or the reason the sandbox could not be started)
85
+ """
86
+ result = _worker.request({"code": code})
87
+
88
+ unavailable = _worker.init_data.get("unavailable")
89
+ if unavailable and result.get("ok"):
90
+ result["data"]["unavailable_modules"] = unavailable
91
+ return result
@@ -0,0 +1,157 @@
1
+ """
2
+ Tools: get_allplan_info, get_python_env_info, list_site_packages
3
+
4
+ Focused tools that expose ALLPLAN application info, the embedded Python
5
+ environment, and installed site-packages as three separate concerns.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ from fastmcp import FastMCP
14
+
15
+ from ..config import AllplanConfig, get_config
16
+ from ..paths import build_sys_path, interpreter_path, framework_version
17
+ from . import READ_ONLY_TOOL_ANNOTATIONS
18
+
19
+
20
+ def _python_version(interpreter_exe: str) -> str:
21
+ """Run ``python --version`` and return the version string, e.g. ``'3.11.9'``."""
22
+ result = subprocess.run(
23
+ [interpreter_exe, "--version"],
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ # Output is either on stdout or stderr depending on Python version
28
+ raw = (result.stdout or result.stderr).strip()
29
+ # "Python 3.11.9" → "3.11.9"
30
+ return raw.removeprefix("Python ").strip()
31
+
32
+
33
+ def _read_metadata_field(metadata_file: Path, field: str) -> str:
34
+ """Return the value of a RFC-822 metadata field, or empty string if absent."""
35
+ prefix = f"{field}: "
36
+ for line in metadata_file.read_text(encoding="utf-8", errors="replace").splitlines():
37
+ if line.startswith(prefix):
38
+ return line[len(prefix):].strip()
39
+ return ""
40
+
41
+
42
+ def _scan_installed_packages(cfg: AllplanConfig) -> list[dict]:
43
+ """Scan ``*site-packages*`` directories in the ALLPLAN sys.path for installed packages.
44
+
45
+ Only packages with a ``.dist-info/METADATA`` file are reported. Deduplication
46
+ keeps the first occurrence, matching Python's own import resolution order.
47
+
48
+ Args:
49
+ cfg: Active ALLPLAN configuration.
50
+
51
+ Returns:
52
+ Sorted list of ``{"name": str, "version": str | None}`` dicts.
53
+ """
54
+ packages: list[dict] = []
55
+ seen: set[str] = set()
56
+
57
+ site_dirs = [
58
+ Path(p) for p in build_sys_path(cfg)
59
+ if "site-packages" in p
60
+ ]
61
+
62
+ for site_dir in site_dirs:
63
+ if not site_dir.is_dir():
64
+ continue
65
+ for dist_info in site_dir.glob("*.dist-info"):
66
+ metadata = dist_info / "METADATA"
67
+ if not metadata.is_file():
68
+ continue
69
+ name = _read_metadata_field(metadata, "Name")
70
+ if not name:
71
+ continue
72
+ key = name.lower()
73
+ if key not in seen:
74
+ seen.add(key)
75
+ version = _read_metadata_field(metadata, "Version")
76
+ packages.append({"name": name, "version": version or None})
77
+
78
+ return sorted(packages, key=lambda p: p["name"].lower())
79
+
80
+
81
+ def register_runtime_context_tools(mcp: FastMCP) -> None:
82
+ """Register all runtime-context tools on the given FastMCP instance."""
83
+
84
+ @mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
85
+ def get_allplan_info() -> dict:
86
+ """
87
+ Get the installed ALLPLAN version and its file-system paths.
88
+
89
+ Returns:
90
+ version: The ALLPLAN (and PythonParts framework) version string.
91
+ paths: The four ALLPLAN installation paths:
92
+ prg - containing program binaries including the embedded Python interpreter,
93
+ etc - shared configuration and resources,
94
+ std - office-scoped resources,
95
+ usr - user-scoped resources.
96
+ """
97
+ try:
98
+ cfg = get_config()
99
+ version = framework_version(cfg)
100
+ except Exception as exc: # noqa: BLE001
101
+ return {"ok": False, "error": str(exc)}
102
+
103
+ return {
104
+ "ok": True,
105
+ "data": {
106
+ "version": version,
107
+
108
+ "paths": {
109
+ "prg": str(cfg.prg),
110
+ "etc": str(cfg.etc),
111
+ "std": str(cfg.std),
112
+ "usr": str(cfg.usr),
113
+ },
114
+ },
115
+ }
116
+
117
+ @mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
118
+ def get_python_env_info() -> dict:
119
+ """
120
+ Get info about the Python environment embedded inside ALLPLAN.
121
+
122
+ This is the interpreter and sys.path that ALLPLAN uses when executing
123
+ PythonParts scripts at runtime.
124
+
125
+ Returns:
126
+ interpreter_path: Absolute path to the ALLPLAN-embedded python.exe.
127
+ python_version: Version string of that interpreter (e.g. '3.11.9').
128
+ sys_path: Ordered list of directories ALLPLAN adds to sys.path.
129
+ """
130
+ cfg = get_config()
131
+ exe = str(interpreter_path(cfg.prg))
132
+
133
+ return {
134
+ "ok": True,
135
+ "data": {
136
+ "interpreter_path": exe,
137
+ "python_version": _python_version(exe),
138
+ "sys_path": build_sys_path(cfg),
139
+ },
140
+ }
141
+
142
+ @mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
143
+ def list_site_packages() -> dict:
144
+ """
145
+ List all Python packages installed in the ALLPLAN-embedded python environment.
146
+
147
+ Returns:
148
+ Deduplicated (first occurrence wins, matching Python's import order) list
149
+ of installed packages sorted alphabetically by package name. Each package represented as a dict
150
+ like ``{"name": str, "version": str | null}``
151
+ """
152
+ cfg = get_config()
153
+
154
+ return {
155
+ "ok": True,
156
+ "data": _scan_installed_packages(cfg),
157
+ }