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.
- pythonparts_mcp-0.2.0.dist-info/METADATA +179 -0
- pythonparts_mcp-0.2.0.dist-info/RECORD +20 -0
- pythonparts_mcp-0.2.0.dist-info/WHEEL +4 -0
- pythonparts_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- server/__init__.py +0 -0
- server/__main__.py +101 -0
- server/config.py +58 -0
- server/paths.py +87 -0
- server/pyright_config.py +67 -0
- server/registry.py +59 -0
- server/tools/__init__.py +23 -0
- server/tools/_introspect_worker.py +537 -0
- server/tools/_sandbox_worker.py +196 -0
- server/tools/connector.py +81 -0
- server/tools/inspect_symbol.py +75 -0
- server/tools/run_snippet_in_allplan.py +85 -0
- server/tools/run_snippet_in_sandbox.py +91 -0
- server/tools/runtime_context.py +157 -0
- server/tools/typecheck.py +140 -0
- server/worker.py +184 -0
|
@@ -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
|
+
}
|