paraview-mcp-python 0.1.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.
- paraview_mcp_python-0.1.0.dist-info/METADATA +339 -0
- paraview_mcp_python-0.1.0.dist-info/RECORD +8 -0
- paraview_mcp_python-0.1.0.dist-info/WHEEL +4 -0
- paraview_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
- paraview_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- paraview_mcp_server/__init__.py +4 -0
- paraview_mcp_server/headless.py +350 -0
- paraview_mcp_server/server.py +671 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Headless pvpython execution helpers used as a fallback transport.
|
|
2
|
+
|
|
3
|
+
This module lets the MCP server launch a separate ``pvpython`` / ``pvbatch``
|
|
4
|
+
process to execute scripts without requiring a running bridge. It mirrors
|
|
5
|
+
the ``HeadlessBlenderExecutor`` from the Blender MCP server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import textwrap
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
RESULT_PREFIX = "__PARAVIEW_MCP_RESULT__="
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cap_output(text: str, limit: int = 50_000) -> str:
|
|
26
|
+
if len(text) <= limit:
|
|
27
|
+
return text
|
|
28
|
+
return text[:limit] + f"\n… (truncated, {len(text)} total chars)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_json(value: Any) -> Any:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
json.dumps(value)
|
|
36
|
+
return value
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return repr(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_payload(stdout: str) -> tuple[dict[str, Any] | None, str]:
|
|
42
|
+
"""Split out the structured result payload from raw pvpython stdout."""
|
|
43
|
+
payload = None
|
|
44
|
+
clean_lines: list[str] = []
|
|
45
|
+
for line in stdout.splitlines():
|
|
46
|
+
if line.startswith(RESULT_PREFIX):
|
|
47
|
+
payload = json.loads(line[len(RESULT_PREFIX) :])
|
|
48
|
+
else:
|
|
49
|
+
clean_lines.append(line)
|
|
50
|
+
cleaned = "\n".join(clean_lines)
|
|
51
|
+
if stdout.endswith("\n"):
|
|
52
|
+
cleaned += "\n"
|
|
53
|
+
return payload, cleaned
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_wrapper_script(code_path: Path, args_path: Path) -> str:
|
|
57
|
+
return textwrap.dedent(
|
|
58
|
+
f"""
|
|
59
|
+
import io
|
|
60
|
+
import json
|
|
61
|
+
import pathlib
|
|
62
|
+
import traceback
|
|
63
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
import paraview.simple as pvs
|
|
67
|
+
except ImportError:
|
|
68
|
+
pvs = None
|
|
69
|
+
|
|
70
|
+
code = pathlib.Path({code_path.as_posix()!r}).read_text(encoding="utf-8")
|
|
71
|
+
args = json.loads(pathlib.Path({args_path.as_posix()!r}).read_text(encoding="utf-8"))
|
|
72
|
+
namespace = {{
|
|
73
|
+
"pvs": pvs,
|
|
74
|
+
"args": args,
|
|
75
|
+
"__result__": None,
|
|
76
|
+
}}
|
|
77
|
+
stdout_buf = io.StringIO()
|
|
78
|
+
stderr_buf = io.StringIO()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
82
|
+
exec(compile(code, "<mcp-headless-script>", "exec"), namespace)
|
|
83
|
+
payload = {{
|
|
84
|
+
"result": namespace.get("__result__"),
|
|
85
|
+
"stdout": stdout_buf.getvalue(),
|
|
86
|
+
"stderr": stderr_buf.getvalue(),
|
|
87
|
+
"error": None,
|
|
88
|
+
"timed_out": False,
|
|
89
|
+
"cancelled": False,
|
|
90
|
+
}}
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
93
|
+
payload = {{
|
|
94
|
+
"result": None,
|
|
95
|
+
"stdout": stdout_buf.getvalue(),
|
|
96
|
+
"stderr": stderr_buf.getvalue(),
|
|
97
|
+
"error": "".join(tb).strip(),
|
|
98
|
+
"timed_out": False,
|
|
99
|
+
"cancelled": False,
|
|
100
|
+
}}
|
|
101
|
+
|
|
102
|
+
print({RESULT_PREFIX!r} + json.dumps(payload, ensure_ascii=True, default=repr))
|
|
103
|
+
"""
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class HeadlessPvpythonExecutor:
|
|
108
|
+
"""Run ParaView scripts in a separate headless ``pvpython`` process."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, pvpython_binary: str | None = None):
|
|
111
|
+
self.pvpython_binary = pvpython_binary or os.environ.get("PVPYTHON_BIN", "pvpython")
|
|
112
|
+
|
|
113
|
+
async def execute(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
code: str | None = None,
|
|
117
|
+
script_path: str | None = None,
|
|
118
|
+
args: dict[str, Any] | None = None,
|
|
119
|
+
timeout_seconds: int | None = None,
|
|
120
|
+
process_holder: dict[str, Any] | None = None,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
if code and script_path:
|
|
123
|
+
raise ValueError("Provide either 'code' or 'script_path', not both")
|
|
124
|
+
if not code and not script_path:
|
|
125
|
+
raise ValueError("Either 'code' or 'script_path' must be provided")
|
|
126
|
+
|
|
127
|
+
if script_path is not None:
|
|
128
|
+
code = Path(script_path).read_text(encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
args = args or {}
|
|
131
|
+
start = time.monotonic()
|
|
132
|
+
|
|
133
|
+
with tempfile.TemporaryDirectory(prefix="paraview-mcp-headless-") as tmpdir:
|
|
134
|
+
tmp = Path(tmpdir)
|
|
135
|
+
code_path = tmp / "script.py"
|
|
136
|
+
args_path = tmp / "args.json"
|
|
137
|
+
wrapper_path = tmp / "wrapper.py"
|
|
138
|
+
|
|
139
|
+
code_path.write_text(code or "", encoding="utf-8")
|
|
140
|
+
args_path.write_text(json.dumps(args), encoding="utf-8")
|
|
141
|
+
wrapper_path.write_text(_build_wrapper_script(code_path, args_path), encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
cmd = [self.pvpython_binary, str(wrapper_path)]
|
|
144
|
+
|
|
145
|
+
proc = await asyncio.create_subprocess_exec(
|
|
146
|
+
*cmd,
|
|
147
|
+
stdout=asyncio.subprocess.PIPE,
|
|
148
|
+
stderr=asyncio.subprocess.PIPE,
|
|
149
|
+
)
|
|
150
|
+
if process_holder is not None:
|
|
151
|
+
process_holder["process"] = proc
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
155
|
+
proc.communicate(),
|
|
156
|
+
timeout=(timeout_seconds if timeout_seconds and timeout_seconds > 0 else None),
|
|
157
|
+
)
|
|
158
|
+
except asyncio.TimeoutError:
|
|
159
|
+
proc.kill()
|
|
160
|
+
stdout_b, stderr_b = await proc.communicate()
|
|
161
|
+
elapsed = time.monotonic() - start
|
|
162
|
+
return {
|
|
163
|
+
"result": None,
|
|
164
|
+
"stdout": _cap_output(stdout_b.decode("utf-8", errors="replace")),
|
|
165
|
+
"stderr": _cap_output(stderr_b.decode("utf-8", errors="replace")),
|
|
166
|
+
"error": f"Execution exceeded timeout of {timeout_seconds}s",
|
|
167
|
+
"duration_seconds": round(elapsed, 4),
|
|
168
|
+
"timed_out": True,
|
|
169
|
+
"cancelled": False,
|
|
170
|
+
}
|
|
171
|
+
except asyncio.CancelledError:
|
|
172
|
+
proc.terminate()
|
|
173
|
+
stdout_b, stderr_b = await proc.communicate()
|
|
174
|
+
elapsed = time.monotonic() - start
|
|
175
|
+
return {
|
|
176
|
+
"result": None,
|
|
177
|
+
"stdout": _cap_output(stdout_b.decode("utf-8", errors="replace")),
|
|
178
|
+
"stderr": _cap_output(stderr_b.decode("utf-8", errors="replace")),
|
|
179
|
+
"error": "Execution cancelled",
|
|
180
|
+
"duration_seconds": round(elapsed, 4),
|
|
181
|
+
"timed_out": False,
|
|
182
|
+
"cancelled": True,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
elapsed = time.monotonic() - start
|
|
186
|
+
stdout = stdout_b.decode("utf-8", errors="replace")
|
|
187
|
+
stderr = stderr_b.decode("utf-8", errors="replace")
|
|
188
|
+
try:
|
|
189
|
+
payload, clean_stdout = _extract_payload(stdout)
|
|
190
|
+
except json.JSONDecodeError as exc:
|
|
191
|
+
error = f"Headless pvpython returned an invalid result payload: {exc}"
|
|
192
|
+
if stderr.strip():
|
|
193
|
+
error = f"{error}\n{stderr.strip()}"
|
|
194
|
+
return {
|
|
195
|
+
"result": None,
|
|
196
|
+
"stdout": _cap_output(stdout),
|
|
197
|
+
"stderr": _cap_output(stderr),
|
|
198
|
+
"error": error,
|
|
199
|
+
"duration_seconds": round(elapsed, 4),
|
|
200
|
+
"timed_out": False,
|
|
201
|
+
"cancelled": False,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if payload is None:
|
|
205
|
+
error = f"Headless pvpython exited with code {proc.returncode} without a result payload"
|
|
206
|
+
if stderr.strip():
|
|
207
|
+
error = f"{error}\n{stderr.strip()}"
|
|
208
|
+
return {
|
|
209
|
+
"result": None,
|
|
210
|
+
"stdout": _cap_output(stdout),
|
|
211
|
+
"stderr": _cap_output(stderr),
|
|
212
|
+
"error": error,
|
|
213
|
+
"duration_seconds": round(elapsed, 4),
|
|
214
|
+
"timed_out": False,
|
|
215
|
+
"cancelled": False,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"result": _safe_json(payload.get("result")),
|
|
220
|
+
"stdout": _cap_output(clean_stdout + payload.get("stdout", "")),
|
|
221
|
+
"stderr": _cap_output(stderr + payload.get("stderr", "")),
|
|
222
|
+
"error": payload.get("error"),
|
|
223
|
+
"duration_seconds": round(elapsed, 4),
|
|
224
|
+
"timed_out": bool(payload.get("timed_out")),
|
|
225
|
+
"cancelled": bool(payload.get("cancelled")),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class HeadlessJobManager:
|
|
230
|
+
"""Track async headless pvpython executions inside the MCP server process."""
|
|
231
|
+
|
|
232
|
+
def __init__(self):
|
|
233
|
+
self._jobs: dict[str, dict[str, Any]] = {}
|
|
234
|
+
|
|
235
|
+
async def create_job(
|
|
236
|
+
self,
|
|
237
|
+
executor: HeadlessPvpythonExecutor,
|
|
238
|
+
*,
|
|
239
|
+
code: str | None = None,
|
|
240
|
+
script_path: str | None = None,
|
|
241
|
+
args: dict[str, Any] | None = None,
|
|
242
|
+
timeout_seconds: int | None = None,
|
|
243
|
+
) -> str:
|
|
244
|
+
job_id = f"headless-job-{uuid.uuid4().hex[:8]}"
|
|
245
|
+
process_holder: dict[str, Any] = {}
|
|
246
|
+
job: dict[str, Any] = {
|
|
247
|
+
"job_id": job_id,
|
|
248
|
+
"status": "queued",
|
|
249
|
+
"created_at": time.time(),
|
|
250
|
+
"started_at": None,
|
|
251
|
+
"completed_at": None,
|
|
252
|
+
"result": None,
|
|
253
|
+
"stdout": "",
|
|
254
|
+
"stderr": "",
|
|
255
|
+
"error": None,
|
|
256
|
+
"cancelled": False,
|
|
257
|
+
"timed_out": False,
|
|
258
|
+
"process_holder": process_holder,
|
|
259
|
+
"task": None,
|
|
260
|
+
}
|
|
261
|
+
self._jobs[job_id] = job
|
|
262
|
+
|
|
263
|
+
async def runner():
|
|
264
|
+
job["status"] = "running"
|
|
265
|
+
job["started_at"] = time.time()
|
|
266
|
+
try:
|
|
267
|
+
result = await executor.execute(
|
|
268
|
+
code=code,
|
|
269
|
+
script_path=script_path,
|
|
270
|
+
args=args,
|
|
271
|
+
timeout_seconds=timeout_seconds,
|
|
272
|
+
process_holder=process_holder,
|
|
273
|
+
)
|
|
274
|
+
except asyncio.CancelledError:
|
|
275
|
+
job["error"] = "Execution cancelled"
|
|
276
|
+
job["cancelled"] = True
|
|
277
|
+
job["status"] = "cancelled"
|
|
278
|
+
job["completed_at"] = time.time()
|
|
279
|
+
raise
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
job["error"] = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).strip()
|
|
282
|
+
job["status"] = "failed"
|
|
283
|
+
job["completed_at"] = time.time()
|
|
284
|
+
else:
|
|
285
|
+
job["result"] = result.get("result")
|
|
286
|
+
job["stdout"] = result.get("stdout", "")
|
|
287
|
+
job["stderr"] = result.get("stderr", "")
|
|
288
|
+
job["error"] = result.get("error")
|
|
289
|
+
job["cancelled"] = bool(result.get("cancelled"))
|
|
290
|
+
job["timed_out"] = bool(result.get("timed_out"))
|
|
291
|
+
job["completed_at"] = time.time()
|
|
292
|
+
if job["cancelled"]:
|
|
293
|
+
job["status"] = "cancelled"
|
|
294
|
+
elif job["error"]:
|
|
295
|
+
job["status"] = "failed"
|
|
296
|
+
else:
|
|
297
|
+
job["status"] = "succeeded"
|
|
298
|
+
finally:
|
|
299
|
+
process_holder.pop("process", None)
|
|
300
|
+
|
|
301
|
+
job["task"] = asyncio.create_task(runner())
|
|
302
|
+
return job_id
|
|
303
|
+
|
|
304
|
+
def get_status(self, job_id: str) -> dict[str, Any]:
|
|
305
|
+
job = self._jobs.get(job_id)
|
|
306
|
+
if not job:
|
|
307
|
+
raise ValueError(f"Unknown job: {job_id}")
|
|
308
|
+
return {
|
|
309
|
+
"job_id": job["job_id"],
|
|
310
|
+
"status": job["status"],
|
|
311
|
+
"created_at": job["created_at"],
|
|
312
|
+
"started_at": job["started_at"],
|
|
313
|
+
"completed_at": job["completed_at"],
|
|
314
|
+
"result": job["result"],
|
|
315
|
+
"stdout": job["stdout"],
|
|
316
|
+
"stderr": job["stderr"],
|
|
317
|
+
"error": job["error"],
|
|
318
|
+
"cancelled": job["cancelled"],
|
|
319
|
+
"timed_out": job["timed_out"],
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
def list_jobs(self) -> dict[str, Any]:
|
|
323
|
+
jobs = [
|
|
324
|
+
{
|
|
325
|
+
"job_id": job["job_id"],
|
|
326
|
+
"status": job["status"],
|
|
327
|
+
"created_at": job["created_at"],
|
|
328
|
+
}
|
|
329
|
+
for job in self._jobs.values()
|
|
330
|
+
]
|
|
331
|
+
return {"jobs": jobs}
|
|
332
|
+
|
|
333
|
+
async def cancel(self, job_id: str) -> dict[str, Any]:
|
|
334
|
+
job = self._jobs.get(job_id)
|
|
335
|
+
if not job:
|
|
336
|
+
raise ValueError(f"Unknown job: {job_id}")
|
|
337
|
+
|
|
338
|
+
proc = job["process_holder"].get("process")
|
|
339
|
+
if proc is not None and proc.returncode is None:
|
|
340
|
+
proc.terminate()
|
|
341
|
+
task = job.get("task")
|
|
342
|
+
if task is not None and not task.done():
|
|
343
|
+
task.cancel()
|
|
344
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
345
|
+
await task
|
|
346
|
+
if job["status"] in {"queued", "running"}:
|
|
347
|
+
job["status"] = "cancelled"
|
|
348
|
+
job["completed_at"] = time.time()
|
|
349
|
+
job["cancelled"] = True
|
|
350
|
+
return {"job_id": job_id, "status": job["status"]}
|