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,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool: typecheck_file
|
|
3
|
+
|
|
4
|
+
Runs pyright against a file using the ALLPLAN runtime context and returns
|
|
5
|
+
structured diagnostics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
|
|
17
|
+
from ..pyright_config import get_pyright_config_dir, get_python_interpreter
|
|
18
|
+
from . import READ_ONLY_TOOL_ANNOTATIONS
|
|
19
|
+
|
|
20
|
+
# Pyright severity labels → canonical names
|
|
21
|
+
_SEVERITY_MAP = {
|
|
22
|
+
"error": "error",
|
|
23
|
+
"warning": "warning",
|
|
24
|
+
"information": "info",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_pyright(file_path: str, config_dir: Path) -> dict:
|
|
29
|
+
"""Invoke pyright and return its parsed JSON output."""
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[
|
|
32
|
+
sys.executable, "-m", "pyright",
|
|
33
|
+
"--outputjson",
|
|
34
|
+
"--project", str(config_dir),
|
|
35
|
+
"--pythonpath", get_python_interpreter(),
|
|
36
|
+
file_path,
|
|
37
|
+
],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
stdin=subprocess.DEVNULL, # prevent inheriting the MCP server's stdin
|
|
41
|
+
timeout=120,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# pyright exits with 1 when diagnostics are found — that is not an error
|
|
45
|
+
# for us. Only fail if we cannot parse the output at all.
|
|
46
|
+
# pyright may print warnings before the JSON object; find the first '{'
|
|
47
|
+
stdout = result.stdout
|
|
48
|
+
json_start = stdout.find("{")
|
|
49
|
+
if json_start == -1:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"pyright produced no JSON output (exit {result.returncode}):\n"
|
|
52
|
+
f"stdout: {stdout}\nstderr: {result.stderr}"
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(stdout[json_start:])
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"pyright produced unparseable output (exit {result.returncode}):\n"
|
|
59
|
+
f"stdout: {stdout}\nstderr: {result.stderr}"
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_false_positive(diagnostic: dict) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Return True for known false positives that should be suppressed.
|
|
66
|
+
|
|
67
|
+
BuildingElement attributes are populated dynamically from the .pyp file at
|
|
68
|
+
runtime, so pyright cannot verify them statically. These errors are expected
|
|
69
|
+
and not actionable.
|
|
70
|
+
"""
|
|
71
|
+
return (
|
|
72
|
+
diagnostic.get("rule") == "reportAttributeAccessIssue"
|
|
73
|
+
and "BuildingElement" in diagnostic.get("message", "")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _map_diagnostics(raw: dict) -> list[dict]:
|
|
78
|
+
"""Flatten pyright's per-file diagnostic list into a single list, excluding false positives."""
|
|
79
|
+
diagnostics: list[dict] = []
|
|
80
|
+
for file_result in raw.get("generalDiagnostics", []):
|
|
81
|
+
severity = _SEVERITY_MAP.get(file_result.get(
|
|
82
|
+
"severity", ""), file_result.get("severity", ""))
|
|
83
|
+
range_ = file_result.get("range", {})
|
|
84
|
+
start = range_.get("start", {})
|
|
85
|
+
entry = {
|
|
86
|
+
"severity": severity,
|
|
87
|
+
"message": file_result.get("message", ""),
|
|
88
|
+
"file": file_result.get("file", ""),
|
|
89
|
+
"line": start.get("line", 0) + 1, # pyright is 0-based
|
|
90
|
+
"column": start.get("character", 0) + 1,
|
|
91
|
+
"rule": file_result.get("rule", ""),
|
|
92
|
+
}
|
|
93
|
+
if not _is_false_positive(entry):
|
|
94
|
+
diagnostics.append(entry)
|
|
95
|
+
return diagnostics
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register_typecheck_tools(mcp: FastMCP) -> None:
|
|
99
|
+
"""Register all type-checking tools on the given FastMCP instance."""
|
|
100
|
+
|
|
101
|
+
@mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
|
|
102
|
+
def typecheck_file(file_path: str) -> dict:
|
|
103
|
+
"""
|
|
104
|
+
Run a full type-check against the given file using the ALLPLAN Python
|
|
105
|
+
runtime and its bundled .pyi stubs.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
file_path: Absolute path to the Python file to check.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
ok: False only if the tool itself failed (e.g. pyright not found).
|
|
112
|
+
data.error_count: Number of errors reported.
|
|
113
|
+
data.warning_count: Number of warnings reported.
|
|
114
|
+
data.diagnostics: List of diagnostic items, each with severity, message,
|
|
115
|
+
file, line, column, and rule fields.
|
|
116
|
+
"""
|
|
117
|
+
path = Path(file_path)
|
|
118
|
+
if not path.is_file():
|
|
119
|
+
return {
|
|
120
|
+
"ok": False,
|
|
121
|
+
"error": f"File not found: {file_path}",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
config_dir = get_pyright_config_dir()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
raw = _run_pyright(file_path, config_dir)
|
|
128
|
+
except RuntimeError as exc:
|
|
129
|
+
return {"ok": False, "error": str(exc)}
|
|
130
|
+
|
|
131
|
+
diagnostics = _map_diagnostics(raw)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"ok": True,
|
|
135
|
+
"data": {
|
|
136
|
+
"error_count": sum(1 for d in diagnostics if d["severity"] == "error"),
|
|
137
|
+
"warning_count": sum(1 for d in diagnostics if d["severity"] == "warning"),
|
|
138
|
+
"diagnostics": diagnostics,
|
|
139
|
+
},
|
|
140
|
+
}
|
server/worker.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent worker subprocesses running under the ALLPLAN-embedded interpreter.
|
|
3
|
+
|
|
4
|
+
ALLPLAN's Python modules are native extensions that can only be imported by the
|
|
5
|
+
interpreter shipped in ``Prg\\Python``, and loading their DLLs costs seconds. A
|
|
6
|
+
worker is therefore spawned once, on first use, and kept alive for the lifetime
|
|
7
|
+
of the MCP server so that cost is paid only once.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import queue
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .config import AllplanConfig, get_config
|
|
23
|
+
from .paths import interpreter_path
|
|
24
|
+
|
|
25
|
+
_SHUTDOWN_TIMEOUT_SECONDS = 5
|
|
26
|
+
_logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkerGoneError(RuntimeError):
|
|
30
|
+
"""The worker process died or fell out of sync with the request/response protocol."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PersistentWorker:
|
|
34
|
+
"""A worker script running under the ALLPLAN interpreter, reused across calls.
|
|
35
|
+
|
|
36
|
+
The protocol is one JSON line in, one JSON line out. The first line sent is an
|
|
37
|
+
init handshake that the worker acknowledges before it accepts any request.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
script: Path,
|
|
43
|
+
build_init_params: Callable[[AllplanConfig], dict],
|
|
44
|
+
*,
|
|
45
|
+
init_timeout: float,
|
|
46
|
+
request_timeout: float,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._script = script
|
|
49
|
+
self._build_init_params = build_init_params
|
|
50
|
+
self._init_timeout = init_timeout
|
|
51
|
+
self._request_timeout = request_timeout
|
|
52
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
53
|
+
self._lines: queue.Queue[str | None] = queue.Queue()
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
self._init_data: dict = {}
|
|
56
|
+
atexit.register(self.shutdown)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def init_data(self) -> dict:
|
|
60
|
+
"""Payload the worker reported in its handshake ack, empty until first use."""
|
|
61
|
+
return self._init_data
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_running(self) -> bool:
|
|
65
|
+
"""Whether the subprocess is alive, per ``Popen.poll()``."""
|
|
66
|
+
return self._proc is not None and self._proc.poll() is None
|
|
67
|
+
|
|
68
|
+
def request(self, payload: dict) -> dict:
|
|
69
|
+
|
|
70
|
+
"""Send one request and return the worker's response, or a structured error.
|
|
71
|
+
|
|
72
|
+
Every failure mode leaves the worker dead but the caller alive: the next
|
|
73
|
+
request spawns a fresh process.
|
|
74
|
+
"""
|
|
75
|
+
with self._lock:
|
|
76
|
+
try:
|
|
77
|
+
proc = self._ensure_running()
|
|
78
|
+
self._write(proc, payload)
|
|
79
|
+
return self._read(self._request_timeout)
|
|
80
|
+
except TimeoutError:
|
|
81
|
+
self._kill()
|
|
82
|
+
return {
|
|
83
|
+
"ok": False,
|
|
84
|
+
"error": f"The worker did not respond within {self._request_timeout:.0f}s "
|
|
85
|
+
"and was restarted. A snippet that blocks forever cannot be recovered "
|
|
86
|
+
"from — make sure it terminates.",
|
|
87
|
+
}
|
|
88
|
+
except (WorkerGoneError, BrokenPipeError, OSError) as exc:
|
|
89
|
+
self._kill()
|
|
90
|
+
return {"ok": False, "error": str(exc)}
|
|
91
|
+
|
|
92
|
+
def shutdown(self) -> None:
|
|
93
|
+
"""Ask the worker to exit by closing its stdin, then kill it if it lingers."""
|
|
94
|
+
proc = self._proc
|
|
95
|
+
if proc is None or proc.poll() is not None:
|
|
96
|
+
self._proc = None
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
if proc.stdin is not None:
|
|
100
|
+
proc.stdin.close()
|
|
101
|
+
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECONDS)
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
103
|
+
proc.kill()
|
|
104
|
+
finally:
|
|
105
|
+
self._proc = None
|
|
106
|
+
|
|
107
|
+
def _ensure_running(self) -> subprocess.Popen[str]:
|
|
108
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
109
|
+
return self._proc
|
|
110
|
+
self._proc = self._spawn(get_config())
|
|
111
|
+
return self._proc
|
|
112
|
+
|
|
113
|
+
def _spawn(self, cfg: AllplanConfig) -> subprocess.Popen[str]:
|
|
114
|
+
self._lines = queue.Queue()
|
|
115
|
+
proc = subprocess.Popen(
|
|
116
|
+
[str(interpreter_path(cfg.prg)), str(self._script)],
|
|
117
|
+
stdin=subprocess.PIPE,
|
|
118
|
+
stdout=subprocess.PIPE,
|
|
119
|
+
stderr=subprocess.DEVNULL,
|
|
120
|
+
text=True,
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
errors="replace",
|
|
123
|
+
bufsize=1,
|
|
124
|
+
)
|
|
125
|
+
self._proc = proc
|
|
126
|
+
|
|
127
|
+
threading.Thread(target=self._pump, args=(proc, self._lines), daemon=True).start()
|
|
128
|
+
|
|
129
|
+
self._write(proc, self._build_init_params(cfg))
|
|
130
|
+
ack = self._read(self._init_timeout)
|
|
131
|
+
if not ack.get("ok"):
|
|
132
|
+
proc.kill()
|
|
133
|
+
raise WorkerGoneError(f"Worker init failed: {ack.get('error', ack)}")
|
|
134
|
+
|
|
135
|
+
self._init_data = ack.get("data", {})
|
|
136
|
+
return proc
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _pump(proc: subprocess.Popen[str], lines: queue.Queue[str | None]) -> None:
|
|
140
|
+
"""Drain the worker's stdout into a queue so reads can be given a deadline."""
|
|
141
|
+
assert proc.stdout is not None
|
|
142
|
+
for line in proc.stdout:
|
|
143
|
+
lines.put(line)
|
|
144
|
+
lines.put(None)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _write(proc: subprocess.Popen[str], payload: dict) -> None:
|
|
148
|
+
if proc.stdin is None:
|
|
149
|
+
raise WorkerGoneError("Worker stdin is not available.")
|
|
150
|
+
proc.stdin.write(json.dumps(payload) + "\n")
|
|
151
|
+
proc.stdin.flush()
|
|
152
|
+
|
|
153
|
+
def _read(self, timeout: float) -> dict:
|
|
154
|
+
deadline = time.monotonic() + timeout
|
|
155
|
+
while True:
|
|
156
|
+
remaining = deadline - time.monotonic()
|
|
157
|
+
if remaining <= 0:
|
|
158
|
+
raise TimeoutError
|
|
159
|
+
try:
|
|
160
|
+
line = self._lines.get(timeout=remaining)
|
|
161
|
+
except queue.Empty:
|
|
162
|
+
raise TimeoutError from None
|
|
163
|
+
|
|
164
|
+
if line is None:
|
|
165
|
+
raise WorkerGoneError("The worker exited unexpectedly.")
|
|
166
|
+
|
|
167
|
+
# The ALLPLAN DLLs occasionally print standalone log lines to stdout, so
|
|
168
|
+
# skip anything before the JSON, and skip lines that contain no JSON at all.
|
|
169
|
+
json_start = line.find("{")
|
|
170
|
+
if json_start == -1:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(line[json_start:])
|
|
174
|
+
except json.JSONDecodeError as exc:
|
|
175
|
+
raise WorkerGoneError(f"Malformed worker response: {exc}. Raw: {line[:300]}") from exc
|
|
176
|
+
|
|
177
|
+
def _kill(self) -> None:
|
|
178
|
+
proc = self._proc
|
|
179
|
+
self._proc = None
|
|
180
|
+
if proc is not None:
|
|
181
|
+
try:
|
|
182
|
+
proc.kill()
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|