ovito-mcp 2026.1.dev2__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.
- ovito_mcp/__init__.py +1275 -0
- ovito_mcp/__main__.py +16 -0
- ovito_mcp/rag/__init__.py +293 -0
- ovito_mcp/rag/db.py +542 -0
- ovito_mcp-2026.1.dev2.dist-info/METADATA +240 -0
- ovito_mcp-2026.1.dev2.dist-info/RECORD +9 -0
- ovito_mcp-2026.1.dev2.dist-info/WHEEL +4 -0
- ovito_mcp-2026.1.dev2.dist-info/entry_points.txt +2 -0
- ovito_mcp-2026.1.dev2.dist-info/licenses/LICENSE +39 -0
ovito_mcp/__init__.py
ADDED
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
"""MCP server for interactive work in an OVITO Jupyter kernel.
|
|
2
|
+
|
|
3
|
+
This server uses ``jupyter_client`` to talk to an OVITO ipykernel and exposes
|
|
4
|
+
tools that execute Python code in that live, persistent kernel session. Because
|
|
5
|
+
OVITO is importable in the kernel, an LLM can drive a real OVITO session
|
|
6
|
+
interactively (load files, apply modifiers, inspect results) while a human
|
|
7
|
+
watches the same session.
|
|
8
|
+
|
|
9
|
+
The standard mode of operation is to attach to a kernel that OVITO Pro has
|
|
10
|
+
already started: the *AI Agent* panel in OVITO Pro's data inspector launches the
|
|
11
|
+
coding agent with a startup prompt carrying the kernel's connection-file path,
|
|
12
|
+
and the agent passes that path to connect_kernel(). No configuration is required
|
|
13
|
+
for this flow. start_kernel() is the fallback for standalone use without a
|
|
14
|
+
running OVITO Pro session; only that path needs OVITO_EXE.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import os
|
|
21
|
+
import queue
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from jupyter_client.blocking.client import BlockingKernelClient
|
|
28
|
+
from jupyter_client.kernelspec import NoSuchKernel
|
|
29
|
+
from jupyter_client.manager import KernelManager
|
|
30
|
+
from jupyter_core.paths import jupyter_runtime_dir
|
|
31
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
32
|
+
|
|
33
|
+
from ovito_mcp import rag
|
|
34
|
+
|
|
35
|
+
# Advanced override: path to the OVITO Pro executable. Only start_kernel() needs it,
|
|
36
|
+
# to launch OVITO itself; the standard connect_kernel() flow never does. Deliberately
|
|
37
|
+
# not validated at import time, so the server always starts without configuration.
|
|
38
|
+
OVITO_EXE: str | None = os.environ.get("OVITO_EXE")
|
|
39
|
+
KERNEL_NAME: str = "ovito-pro"
|
|
40
|
+
RUNTIME_DIR = jupyter_runtime_dir()
|
|
41
|
+
|
|
42
|
+
# Advanced override: path to the documentation database. Normally left unset — the
|
|
43
|
+
# database ships inside OVITO Pro and is located next to the 'ovito' package via the
|
|
44
|
+
# connected kernel (see _discover_rag_db()). Set it only to point the doc tools at a
|
|
45
|
+
# different database, e.g. one built by hand during development.
|
|
46
|
+
RAG_DB: str | None = os.environ.get("OVITO_RAG_DB")
|
|
47
|
+
# Lazily constructed ovito_mcp.rag.DocsIndex; see _docs_index().
|
|
48
|
+
_docs_index_instance = None
|
|
49
|
+
|
|
50
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
51
|
+
|
|
52
|
+
_MISSING_OVITO_EXE = (
|
|
53
|
+
"start_kernel() needs the OVITO_EXE environment variable — the path to the OVITO "
|
|
54
|
+
"Pro executable — so this server can launch OVITO itself. In the standard setup "
|
|
55
|
+
"you do not need it: start the coding agent from the 'AI Agent' panel in OVITO "
|
|
56
|
+
"Pro's data inspector, then call connect_kernel(<connection_file>) with the path "
|
|
57
|
+
"given in the startup prompt to attach to the already-running session."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Session state (the stdio server is a single long-lived process, so the
|
|
63
|
+
# kernel connection persists across tool calls).
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Session:
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.kc: BlockingKernelClient | None = None
|
|
70
|
+
# km is set only for kernels this server started itself. When we merely
|
|
71
|
+
# connect to an already-running kernel via its connection file, kc is set
|
|
72
|
+
# but km stays None (we do not own that kernel's lifecycle).
|
|
73
|
+
self.km: KernelManager | None = None
|
|
74
|
+
self.connection_file: str | None = None
|
|
75
|
+
self.window_open: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_session = _Session()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Helpers
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _id_from_path(path: str | os.PathLike) -> str:
|
|
87
|
+
name = Path(path).name
|
|
88
|
+
if name.startswith("kernel-") and name.endswith(".json"):
|
|
89
|
+
return name[len("kernel-") : -len(".json")]
|
|
90
|
+
return name
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_active() -> BlockingKernelClient:
|
|
94
|
+
if _session.kc is None:
|
|
95
|
+
result = start_kernel()
|
|
96
|
+
if _session.kc is None:
|
|
97
|
+
raise RuntimeError(f"Failed to start kernel: {result}")
|
|
98
|
+
return _session.kc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_ipykernel_installed() -> str | None:
|
|
102
|
+
"""Install ``ipykernel`` into OVITO_EXE's Python environment if it is missing.
|
|
103
|
+
|
|
104
|
+
The kernelspec launches ``OVITO_EXE --kernel <connection_file>``, which imports
|
|
105
|
+
ipykernel internally — OVITO does not bundle it. OVITO_EXE has no real ``-m``
|
|
106
|
+
passthrough (it silently ignores the flag), so pip must be driven through its
|
|
107
|
+
internal API via ``--exec`` instead of ``OVITO_EXE -m pip install ...``.
|
|
108
|
+
|
|
109
|
+
This is the first point on the launch path that actually needs the OVITO
|
|
110
|
+
executable, so it is where OVITO_EXE is checked — nothing else in the server
|
|
111
|
+
requires it.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
None if ipykernel is present (or was just installed), otherwise an
|
|
115
|
+
error message describing the failure.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If OVITO_EXE is not set.
|
|
119
|
+
"""
|
|
120
|
+
if not OVITO_EXE:
|
|
121
|
+
raise ValueError(_MISSING_OVITO_EXE)
|
|
122
|
+
|
|
123
|
+
check = subprocess.run(
|
|
124
|
+
[OVITO_EXE, "--nogui", "--exec", "import ipykernel"],
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
timeout=30,
|
|
128
|
+
)
|
|
129
|
+
if check.returncode == 0:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
install = subprocess.run(
|
|
133
|
+
[
|
|
134
|
+
OVITO_EXE,
|
|
135
|
+
"--nogui",
|
|
136
|
+
"--exec",
|
|
137
|
+
"from pip._internal.cli.main import main; "
|
|
138
|
+
"raise SystemExit(main(['install', 'ipykernel']))",
|
|
139
|
+
],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=300,
|
|
143
|
+
)
|
|
144
|
+
if install.returncode != 0:
|
|
145
|
+
return (
|
|
146
|
+
"Failed to install ipykernel into the OVITO_EXE Python environment:\n"
|
|
147
|
+
f"{install.stdout}{install.stderr}"
|
|
148
|
+
)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _teardown_current() -> None:
|
|
153
|
+
"""Tear down the active session before starting or connecting to another kernel.
|
|
154
|
+
|
|
155
|
+
A kernel this server started (km set) is shut down; a kernel we merely connected
|
|
156
|
+
to (km None, kc set) is left running — we only drop our client channels.
|
|
157
|
+
"""
|
|
158
|
+
if _session.km is not None:
|
|
159
|
+
_close_window_if_open()
|
|
160
|
+
_session.km.shutdown_kernel(now=True)
|
|
161
|
+
if _session.kc is not None:
|
|
162
|
+
_session.kc.stop_channels()
|
|
163
|
+
_session.kc = None
|
|
164
|
+
_session.km = None
|
|
165
|
+
_session.connection_file = None
|
|
166
|
+
_session.window_open = False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _close_window_if_open() -> None:
|
|
170
|
+
"""Close the OVITO GUI window in the kernel before a restart or shutdown.
|
|
171
|
+
|
|
172
|
+
Acts only when this server opened a window itself (window_open is set True
|
|
173
|
+
exclusively by create_window() after it actually opens a new window — never when
|
|
174
|
+
reusing a pre-existing one), so a window that already existed before this server
|
|
175
|
+
attached is not closed. Note that the window actually closed is whichever one
|
|
176
|
+
MainWindow.get_current() reports as active, not a handle retained from
|
|
177
|
+
create_window().
|
|
178
|
+
"""
|
|
179
|
+
if not _session.window_open or _session.kc is None:
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
# Wrapped in a function so the reference to the window is dropped as soon as the
|
|
183
|
+
# cell finishes. A name left bound in the kernel namespace would keep the closed
|
|
184
|
+
# window object alive until the next tool call rebinds it.
|
|
185
|
+
execute(
|
|
186
|
+
"def _ovito_mcp_close():\n"
|
|
187
|
+
" import ovito.gui\n"
|
|
188
|
+
" win = ovito.gui.MainWindow.get_current()\n"
|
|
189
|
+
" if win is not None:\n"
|
|
190
|
+
" win.widget.close()\n"
|
|
191
|
+
"_ovito_mcp_close()\n"
|
|
192
|
+
"del _ovito_mcp_close\n",
|
|
193
|
+
timeout=5.0,
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
_session.window_open = False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# MCP server
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
mcp = FastMCP(
|
|
205
|
+
"ovito-mcp",
|
|
206
|
+
instructions=(
|
|
207
|
+
"Interactive access to an OVITO Jupyter kernel. State (variables, imports, the "
|
|
208
|
+
"OVITO pipeline) carries across calls, and the 'ovito' package is importable in "
|
|
209
|
+
"the kernel. "
|
|
210
|
+
"\n\n"
|
|
211
|
+
"CONNECTING — The normal case is that OVITO Pro is already running and started "
|
|
212
|
+
"the kernel for you: your startup prompt contains a connection-file path, so "
|
|
213
|
+
"call connect_kernel(<that path>) first and work in that live session, which a "
|
|
214
|
+
"human is watching and can interact with. Only if you were given no connection "
|
|
215
|
+
"file should you fall back to start_kernel(), which launches a separate OVITO "
|
|
216
|
+
"process and requires the OVITO_EXE environment variable to be set. Calling "
|
|
217
|
+
"execute(code) with no active kernel attempts start_kernel() implicitly — so if "
|
|
218
|
+
"a connection file was provided, connect first rather than letting that happen. "
|
|
219
|
+
"Use interrupt_kernel/restart_kernel/shutdown_kernel for lifecycle control. "
|
|
220
|
+
"Call create_window() to open OVITO's full graphical main window so a human can "
|
|
221
|
+
"watch — and interact with — the scene the kernel is building, live. "
|
|
222
|
+
"Call screenshot_window() by default for any visual check — verifying a modifier's "
|
|
223
|
+
"effect, camera framing, or general progress — not just when the user explicitly "
|
|
224
|
+
"asks to see the window. It is a cheap window grab, not a real render, though "
|
|
225
|
+
"screenshots are still large and consume many tokens, so don't call it gratuitously. "
|
|
226
|
+
"It also captures any dialog windows currently open on top of the main window, so a "
|
|
227
|
+
"modal dialog blocking the GUI is visible to you rather than silently missing. "
|
|
228
|
+
"Call set_current_frame(frame) to navigate the animation timeline — do NOT set "
|
|
229
|
+
"ovito.scene.anim.current_frame directly in execute(), as the tool is the canonical "
|
|
230
|
+
"way to advance the timeline and keeps the GUI in sync. "
|
|
231
|
+
"Call list_pipeline_status() to check every pipeline, modifier, and visual element "
|
|
232
|
+
"for the yellow/red warning or error triangles shown in the GUI. "
|
|
233
|
+
"\n\n"
|
|
234
|
+
"DOCUMENTATION — If the result shows an error or unexpected output, "
|
|
235
|
+
"do NOT retry blindly — consult the documentation first: call "
|
|
236
|
+
"search_ovito_docs(query) to run a full-text search across the OVITO manual and "
|
|
237
|
+
"Python API stubs and return the most relevant full pages/docstrings. Use it "
|
|
238
|
+
"whenever you need exact parameter names, allowed values, step-by-step procedures, "
|
|
239
|
+
"or code examples. Do not guess at OVITO APIs — search the docs first. When in "
|
|
240
|
+
"doubt, search — it is cheaper than a failed execute() call. Results are always "
|
|
241
|
+
"the complete manual page or docstring, not a truncated summary or excerpt, so "
|
|
242
|
+
"you should not need a separate follow-up fetch in the common case."
|
|
243
|
+
"\n\n"
|
|
244
|
+
"GUI ERRORS (yellow or red triangle) — If the OVITO GUI shows a warning or error "
|
|
245
|
+
"indicator (yellow or red triangle on a pipeline or modifier), call "
|
|
246
|
+
"list_pipeline_status() first to see the exact status_code/status_text behind every "
|
|
247
|
+
"triangle (pipelines, modifiers, and visual elements alike) before doing anything "
|
|
248
|
+
"else — this is the fastest way to identify which node is failing and why. Use that "
|
|
249
|
+
"output together with the documentation to diagnose the issue. Only if it still "
|
|
250
|
+
"cannot be resolved after consulting the documentation, call restart_kernel(). "
|
|
251
|
+
"OVITO's scene is stateful and some error conditions are caused by accumulated state "
|
|
252
|
+
"that only a kernel restart can clear. After restarting, rebuild the pipeline from "
|
|
253
|
+
"scratch."
|
|
254
|
+
"\n\n"
|
|
255
|
+
"PREFER OVITO BUILT-INS — Always prefer OVITO's own modifiers, utilities, and "
|
|
256
|
+
"helper functions over custom implementations, and prefer higher-level abstractions "
|
|
257
|
+
"over lower-level ones. Examples:\n"
|
|
258
|
+
"- Use DisplacementVectors modifier instead of manually computing atom displacements.\n"
|
|
259
|
+
"- Use SimulationCell.delta_vector() to compute minimum-image distances/vectors "
|
|
260
|
+
"instead of writing custom periodic boundary condition logic.\n"
|
|
261
|
+
"- Use PolyhedralTemplateMatchingModifier.calculate_misorientation(a, b, "
|
|
262
|
+
"symmetry='none'|'cubic'|'hexagonal') for grain misorientation/disorientation angles "
|
|
263
|
+
"instead of custom quaternion math — it already does the lattice-symmetry "
|
|
264
|
+
"reduction into the fundamental zone (see below).\n"
|
|
265
|
+
"- Use CommonNeighborAnalysis, IdentifyDiamond, or other structure identification "
|
|
266
|
+
"modifiers instead of re-implementing crystal structure detection.\n"
|
|
267
|
+
"Before writing any geometry, distance, or crystallography calculation from scratch, "
|
|
268
|
+
"search the docs to see if OVITO already provides it — it almost certainly does, "
|
|
269
|
+
"and the built-in version handles edge cases (PBC, triclinic cells, etc.) correctly."
|
|
270
|
+
"\n\n"
|
|
271
|
+
"SILENT-FAILURE RISK: SYMMETRY / QUATERNION MATH — Never hand-roll a crystal-"
|
|
272
|
+
"symmetry reduction (e.g. minimizing a misorientation angle over the 24/48 cubic "
|
|
273
|
+
"point-group operators to find a disorientation) directly in execute() code. This "
|
|
274
|
+
"class of bug does NOT raise an error or show a pipeline warning triangle — it "
|
|
275
|
+
"silently returns a plausible-looking but wrong number (e.g. a spuriously small "
|
|
276
|
+
"angle that happens to land near a well-known CSL value), so the normal 'if it "
|
|
277
|
+
"errors, check the docs' reflex never fires and the wrong answer gets reported as "
|
|
278
|
+
"fact. Treat any orientation/misorientation/disorientation/CSL question as a hard "
|
|
279
|
+
"requirement to search the docs for the built-in first (start with "
|
|
280
|
+
"PolyhedralTemplateMatchingModifier.calculate_misorientation) — do not attempt "
|
|
281
|
+
"your own quaternion symmetry loop even if it looks like a small, self-contained "
|
|
282
|
+
"calculation. If you ever do end up trusting a self-derived numeric result for "
|
|
283
|
+
"something OVITO might already compute, say so explicitly and flag it as "
|
|
284
|
+
"unverified rather than reporting it with full confidence."
|
|
285
|
+
"\n\n"
|
|
286
|
+
"RENDERING TO DISK — Do NOT render images or videos to disk (e.g. via "
|
|
287
|
+
"Viewport.render_image(), Viewport.render_anim(), or any file export that produces "
|
|
288
|
+
"image/video output) unless the user explicitly asks for it in that turn. This "
|
|
289
|
+
"applies every time, not just the first time in a session — do not render again "
|
|
290
|
+
"later in the same conversation just because a render happened once before. "
|
|
291
|
+
"Rendering is slow and may produce large files the user does not want. Always use "
|
|
292
|
+
"screenshot_window() for visual inspection instead."
|
|
293
|
+
"\n\n"
|
|
294
|
+
"WRITING SCRIPTS TO DISK — Do NOT save the Python code you run through execute() "
|
|
295
|
+
"to a .py file (or any other file) unless the user explicitly asks you to keep or "
|
|
296
|
+
"export a script. Pass the code straight to execute() instead. The kernel already "
|
|
297
|
+
"persists state across calls, so there is no need for a script file just to "
|
|
298
|
+
"re-run or iterate on code."
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@mcp.tool()
|
|
304
|
+
def start_kernel() -> str:
|
|
305
|
+
"""Start a new OVITO Jupyter kernel and make it the active session.
|
|
306
|
+
|
|
307
|
+
This launches a *separate* OVITO process and therefore requires the OVITO_EXE
|
|
308
|
+
environment variable. Prefer connect_kernel() when OVITO Pro is already running
|
|
309
|
+
and has supplied a connection file — that is the standard flow and needs no
|
|
310
|
+
configuration.
|
|
311
|
+
|
|
312
|
+
If a kernel is already running it is shut down first. The kernelspec's
|
|
313
|
+
launcher is normalized to OVITO's interpreter so the kernel runs under
|
|
314
|
+
OVITO regardless of PATH. Before launching, ``ipykernel`` is installed into
|
|
315
|
+
OVITO_EXE's Python environment if it isn't already present.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Confirmation with the new kernel's id.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
ValueError: if OVITO_EXE is not set.
|
|
322
|
+
"""
|
|
323
|
+
if (err := _ensure_ipykernel_installed()) is not None:
|
|
324
|
+
return err
|
|
325
|
+
|
|
326
|
+
_teardown_current()
|
|
327
|
+
try:
|
|
328
|
+
km = KernelManager(kernel_name=KERNEL_NAME, transport="ipc")
|
|
329
|
+
except NoSuchKernel:
|
|
330
|
+
return (
|
|
331
|
+
f"Kernelspec '{KERNEL_NAME}' not found. Run `jupyter kernelspec list` under "
|
|
332
|
+
"ovitos to confirm it is installed."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Write the connection file into the Jupyter runtime dir with a standard
|
|
336
|
+
# kernel-<id>.json name, so the kernel shows up in `jupyter console --existing`.
|
|
337
|
+
os.makedirs(RUNTIME_DIR, exist_ok=True)
|
|
338
|
+
km.connection_file = os.path.join(RUNTIME_DIR, f"kernel-{uuid.uuid4()}.json")
|
|
339
|
+
|
|
340
|
+
# Use ZMQ IPC transport (Unix-domain socket files) so kernel messaging stays
|
|
341
|
+
# local and does not open a network port.
|
|
342
|
+
assert km.kernel_spec is not None
|
|
343
|
+
km.kernel_spec.argv = [
|
|
344
|
+
OVITO_EXE,
|
|
345
|
+
"--kernel",
|
|
346
|
+
"{connection_file}",
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
km.start_kernel()
|
|
350
|
+
kc = km.client()
|
|
351
|
+
kc.start_channels()
|
|
352
|
+
try:
|
|
353
|
+
kc.wait_for_ready(timeout=60)
|
|
354
|
+
except RuntimeError as exc:
|
|
355
|
+
km.shutdown_kernel(now=True)
|
|
356
|
+
return f"Kernel started but did not become ready: {exc}"
|
|
357
|
+
|
|
358
|
+
_session.kc = kc
|
|
359
|
+
_session.km = km
|
|
360
|
+
_session.connection_file = km.connection_file
|
|
361
|
+
return f"Started OVITO kernel (id {_id_from_path(km.connection_file)})."
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@mcp.tool()
|
|
365
|
+
def connect_kernel(connection_file: str) -> str:
|
|
366
|
+
"""Connect to an already-running Jupyter kernel via its connection file.
|
|
367
|
+
|
|
368
|
+
Attaches to a kernel that was started outside this server (e.g. one launched from
|
|
369
|
+
an already running OVITO GUI session) instead of starting a new
|
|
370
|
+
one. State in that kernel (variables, imports, the OVITO pipeline) is shared: code
|
|
371
|
+
run via execute() executes in the same live session a human may also be driving.
|
|
372
|
+
|
|
373
|
+
Because this server does not own an externally-started kernel, it will not shut that
|
|
374
|
+
kernel down — start_kernel(), connect_kernel(), or shutdown_kernel() only detach our
|
|
375
|
+
client. Any kernel this server had started itself is shut down first.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
connection_file: Path to the kernel's JSON connection file. May be an absolute
|
|
379
|
+
path, or just the file name (e.g. "kernel-<id>.json"), in which case it is
|
|
380
|
+
looked up in the Jupyter runtime directory. Run `jupyter console --existing`
|
|
381
|
+
or check the runtime dir to find running kernels' connection files.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Confirmation or an error if the file is missing or the kernel does not respond.
|
|
385
|
+
"""
|
|
386
|
+
path = Path(connection_file).expanduser()
|
|
387
|
+
if not path.is_file():
|
|
388
|
+
candidate = Path(RUNTIME_DIR) / path.name
|
|
389
|
+
if candidate.is_file():
|
|
390
|
+
path = candidate
|
|
391
|
+
if not path.is_file():
|
|
392
|
+
return (
|
|
393
|
+
f"Connection file not found: {connection_file}. Provide an absolute path, "
|
|
394
|
+
f"or a kernel-<id>.json name present in the runtime dir ({RUNTIME_DIR})."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
_teardown_current()
|
|
398
|
+
|
|
399
|
+
kc = BlockingKernelClient()
|
|
400
|
+
try:
|
|
401
|
+
kc.load_connection_file(str(path))
|
|
402
|
+
kc.start_channels()
|
|
403
|
+
kc.wait_for_ready(timeout=60)
|
|
404
|
+
except Exception as exc: # noqa: BLE001 - surface any connection failure to the LLM
|
|
405
|
+
kc.stop_channels()
|
|
406
|
+
return f"Failed to connect to kernel at {path}: {exc}"
|
|
407
|
+
|
|
408
|
+
_session.kc = kc
|
|
409
|
+
_session.connection_file = str(path)
|
|
410
|
+
return (
|
|
411
|
+
f"You are now connected to existing OVITO session. Ask the user what they'd like to do with OVITO unless they have already specified an action."
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
def execute(code: str, timeout: float = 60.0) -> str:
|
|
417
|
+
"""Execute Python code in the active OVITO kernel and return its output.
|
|
418
|
+
|
|
419
|
+
State persists across calls (variables, imports, the OVITO pipeline). The 'ovito'
|
|
420
|
+
package is importable. Captures stdout/stderr, the result value, and tracebacks.
|
|
421
|
+
Binary outputs (e.g. images from display) are summarized, not returned as bytes.
|
|
422
|
+
|
|
423
|
+
Do NOT call Viewport.render_image(), Viewport.render_anim(), or export_file(...)
|
|
424
|
+
with an image/video format through this tool unless the user explicitly asked for
|
|
425
|
+
a render or export. For any "let me check what this looks like" need — verifying
|
|
426
|
+
a modifier's effect, camera framing, progress on a pipeline — call
|
|
427
|
+
screenshot_window() instead; it's a cheap window grab, not a real render.
|
|
428
|
+
|
|
429
|
+
If the result shows an error or unexpected output, do NOT retry blindly — consult
|
|
430
|
+
the documentation first: call search_ovito_docs(query) to look up the correct API.
|
|
431
|
+
Guessing at fixes without checking the docs wastes attempts and risks deeper confusion.
|
|
432
|
+
|
|
433
|
+
Do NOT write the code you intend to run out to a .py file on disk first — just pass
|
|
434
|
+
it directly as the `code` argument. Only save code to a file if the user explicitly
|
|
435
|
+
asks for a script to be kept or exported.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
code: Python source to run in the kernel.
|
|
439
|
+
timeout: Max seconds to wait for output before returning partial results
|
|
440
|
+
(the kernel keeps running).
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
A text report: status, captured streams, result value, and any error.
|
|
444
|
+
"""
|
|
445
|
+
kc = _require_active()
|
|
446
|
+
msg_id = kc.execute(code)
|
|
447
|
+
|
|
448
|
+
stdout: list[str] = []
|
|
449
|
+
stderr: list[str] = []
|
|
450
|
+
results: list[str] = []
|
|
451
|
+
error: str | None = None
|
|
452
|
+
extras: list[str] = []
|
|
453
|
+
count: int | None = None
|
|
454
|
+
timed_out = False
|
|
455
|
+
|
|
456
|
+
# Drain IOPub until the kernel reports idle for our request.
|
|
457
|
+
while True:
|
|
458
|
+
try:
|
|
459
|
+
msg = kc.get_iopub_msg(timeout=timeout)
|
|
460
|
+
except queue.Empty:
|
|
461
|
+
extras.append(
|
|
462
|
+
f"[timed out after {timeout:.0f}s waiting for output; "
|
|
463
|
+
"the kernel is still running]"
|
|
464
|
+
)
|
|
465
|
+
timed_out = True
|
|
466
|
+
break
|
|
467
|
+
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
468
|
+
continue
|
|
469
|
+
mtype = msg["msg_type"]
|
|
470
|
+
content = msg["content"]
|
|
471
|
+
if mtype == "status":
|
|
472
|
+
if content.get("execution_state") == "idle":
|
|
473
|
+
break
|
|
474
|
+
elif mtype == "execute_input":
|
|
475
|
+
count = content.get("execution_count", count)
|
|
476
|
+
elif mtype == "stream":
|
|
477
|
+
(stdout if content.get("name") == "stdout" else stderr).append(
|
|
478
|
+
content.get("text", "")
|
|
479
|
+
)
|
|
480
|
+
elif mtype in ("execute_result", "display_data"):
|
|
481
|
+
data = content.get("data", {})
|
|
482
|
+
if "text/plain" in data:
|
|
483
|
+
results.append(data["text/plain"])
|
|
484
|
+
for mime in data:
|
|
485
|
+
if mime != "text/plain":
|
|
486
|
+
extras.append(f"[{mime} output omitted]")
|
|
487
|
+
elif mtype == "error":
|
|
488
|
+
tb = _ANSI_RE.sub("", "\n".join(content.get("traceback", [])))
|
|
489
|
+
error = tb or f"{content.get('ename')}: {content.get('evalue')}"
|
|
490
|
+
|
|
491
|
+
# Shell reply: authoritative overall status + execution count. Read until we find the
|
|
492
|
+
# reply for our request (other queued replies, if any, are skipped).
|
|
493
|
+
status = "error" if error else "ok"
|
|
494
|
+
if not timed_out:
|
|
495
|
+
for _ in range(5):
|
|
496
|
+
try:
|
|
497
|
+
reply = kc.get_shell_msg(timeout=10)
|
|
498
|
+
except queue.Empty:
|
|
499
|
+
break
|
|
500
|
+
if reply.get("parent_header", {}).get("msg_id") == msg_id:
|
|
501
|
+
rc = reply["content"]
|
|
502
|
+
status = rc.get("status", status)
|
|
503
|
+
count = rc.get("execution_count", count)
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
out: list[str] = []
|
|
507
|
+
header = f"[{status}]" + (f" (In[{count}])" if count is not None else "")
|
|
508
|
+
out.append(header)
|
|
509
|
+
if stdout:
|
|
510
|
+
out.append("--- stdout ---\n" + "".join(stdout).rstrip())
|
|
511
|
+
if stderr:
|
|
512
|
+
out.append("--- stderr ---\n" + "".join(stderr).rstrip())
|
|
513
|
+
if results:
|
|
514
|
+
out.append("--- result ---\n" + "\n".join(r.rstrip() for r in results))
|
|
515
|
+
if error:
|
|
516
|
+
out.append("--- error ---\n" + error.rstrip())
|
|
517
|
+
if extras:
|
|
518
|
+
out.append("\n".join(extras))
|
|
519
|
+
if len(out) == 1:
|
|
520
|
+
out.append("(no output)")
|
|
521
|
+
return "\n".join(out)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@mcp.tool()
|
|
525
|
+
def create_window(contents: str | None = None) -> str:
|
|
526
|
+
"""Open OVITO's graphical main window so the user can watch what the kernel does.
|
|
527
|
+
|
|
528
|
+
Launches the full OVITO GUI (interactive pipeline editor, viewports, data inspector)
|
|
529
|
+
in the active kernel via ovito.gui.MainWindow(). Changes made to the scene,
|
|
530
|
+
pipelines, or viewports from execute() are synchronized with the window in both
|
|
531
|
+
directions, so a human can see — and interact with — the live session in real time.
|
|
532
|
+
Use this when the user wants to visually follow along with the work.
|
|
533
|
+
|
|
534
|
+
Requirements:
|
|
535
|
+
* An OVITO Pro Jupyter kernel. The standalone PyPI 'ovito' package has no GUI and
|
|
536
|
+
will raise a RuntimeError.
|
|
537
|
+
* A running Qt event loop in the kernel. The OVITO Pro kernel provides this; in a
|
|
538
|
+
plain ipykernel run execute("%gui qt") first if the window appears frozen.
|
|
539
|
+
|
|
540
|
+
If a window is already open in the kernel — including one that existed before this
|
|
541
|
+
server attached via connect_kernel() — and contents is omitted, the existing window
|
|
542
|
+
is reused instead of opening a duplicate. Note: reusing a pre-existing window does
|
|
543
|
+
NOT make this server its owner — close_window() stays a no-op unless this server
|
|
544
|
+
actually opened a window itself.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
contents: Optional name (or expression) of a Pipeline or Viewport already defined
|
|
548
|
+
in the kernel to display, e.g. "pipeline". If omitted, the current
|
|
549
|
+
ovito.scene (all added pipelines) is shown.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
The kernel's report from opening (or reusing) the window.
|
|
553
|
+
"""
|
|
554
|
+
# The new window is not bound to a name: it stays alive because Qt owns the shown
|
|
555
|
+
# top-level widget, and leaving no reference behind lets it be released on close.
|
|
556
|
+
arg = contents.strip() if contents else ""
|
|
557
|
+
if arg:
|
|
558
|
+
code = (
|
|
559
|
+
"import ovito.gui\n"
|
|
560
|
+
f"ovito.gui.MainWindow({arg})\n"
|
|
561
|
+
"print('OVITO main window opened.')"
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
code = (
|
|
565
|
+
"import ovito.gui\n"
|
|
566
|
+
"if ovito.gui.MainWindow.get_current() is not None:\n"
|
|
567
|
+
" print('Reusing already-open OVITO main window.')\n"
|
|
568
|
+
"else:\n"
|
|
569
|
+
" ovito.gui.MainWindow()\n"
|
|
570
|
+
" print('OVITO main window opened.')\n"
|
|
571
|
+
)
|
|
572
|
+
# create_window() returns immediately only if the kernel has an integrated Qt event
|
|
573
|
+
# loop; otherwise it runs the GUI loop inline and the cell never reaches idle. Use a
|
|
574
|
+
# short timeout so the tool reports back quickly instead of blocking the agent for the
|
|
575
|
+
# full default. A timeout here means the window opened but the kernel is now busy
|
|
576
|
+
# driving its own event loop (no integrated %gui qt) — see the docstring requirements.
|
|
577
|
+
result = execute(code, timeout=5.0)
|
|
578
|
+
# Only claim ownership (and thus become eligible for close_window()) when this call
|
|
579
|
+
# actually created a new window — never for the "reusing" branch, so a pre-existing
|
|
580
|
+
# window is never later closed by this server.
|
|
581
|
+
if "OVITO main window opened." in result:
|
|
582
|
+
_session.window_open = True
|
|
583
|
+
return result
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@mcp.tool()
|
|
587
|
+
def close_window() -> str:
|
|
588
|
+
"""Close the OVITO GUI window this server opened.
|
|
589
|
+
|
|
590
|
+
A no-op unless this server itself opened a window via create_window(): a window that
|
|
591
|
+
already existed before this server attached (e.g. via connect_kernel()), even if
|
|
592
|
+
create_window() later reused it for display, is left alone.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Confirmation that the window was closed, or a message if no window was open.
|
|
596
|
+
"""
|
|
597
|
+
if not _session.window_open:
|
|
598
|
+
return "No OVITO window is open."
|
|
599
|
+
_close_window_if_open()
|
|
600
|
+
return "OVITO window closed."
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@mcp.tool(structured_output=False)
|
|
604
|
+
def screenshot_window() -> list[str | Image]:
|
|
605
|
+
"""Capture a screenshot of the open OVITO GUI window and return it as an image.
|
|
606
|
+
|
|
607
|
+
This is the default, preferred way to inspect the scene visually — a cheap
|
|
608
|
+
window grab, not a real render. Use it for any "let me check what this looks
|
|
609
|
+
like" need: verifying a modifier's effect, camera framing, or general progress.
|
|
610
|
+
Prefer this over Viewport.render_image()/render_anim() or image/video export,
|
|
611
|
+
which are slow and should only run when the user explicitly asks for a render.
|
|
612
|
+
|
|
613
|
+
Grabs the current visual state of the OVITO main window (viewports, pipeline
|
|
614
|
+
editor, data inspector) as a PNG. Any dialog windows belonging to the main window
|
|
615
|
+
that are currently visible are captured as well, so a modal dialog blocking the GUI
|
|
616
|
+
shows up here instead of silently going unnoticed.
|
|
617
|
+
|
|
618
|
+
Requires an open OVITO window — call create_window() first. Also finds a window
|
|
619
|
+
that was already open before this server attached (e.g. via connect_kernel()),
|
|
620
|
+
not just one opened by create_window().
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
One PNG image per captured window — the main window first, then any visible
|
|
624
|
+
dialogs — each preceded by a line naming that window's title.
|
|
625
|
+
"""
|
|
626
|
+
# Wrapped in a function so the window object and the captured pixmaps are released as
|
|
627
|
+
# soon as the cell finishes, rather than lingering as names in the kernel namespace.
|
|
628
|
+
code = (
|
|
629
|
+
"def _ovito_mcp_screenshot():\n"
|
|
630
|
+
" import base64, ovito.gui\n"
|
|
631
|
+
" from PySide6.QtCore import QBuffer, QIODevice\n"
|
|
632
|
+
" win = ovito.gui.MainWindow.get_current()\n"
|
|
633
|
+
" if win is None:\n"
|
|
634
|
+
" print('ERROR: No OVITO window open. Call create_window() first.')\n"
|
|
635
|
+
" return\n"
|
|
636
|
+
" for pixmap, title in win.take_screenshot():\n"
|
|
637
|
+
" buf = QBuffer()\n"
|
|
638
|
+
" buf.open(QIODevice.OpenModeFlag.WriteOnly)\n"
|
|
639
|
+
" pixmap.save(buf, 'PNG')\n"
|
|
640
|
+
" print('SHOT\\t' + ' '.join(str(title).split()) + '\\t'\n"
|
|
641
|
+
" + base64.b64encode(bytes(buf.data())).decode('ascii'))\n"
|
|
642
|
+
"_ovito_mcp_screenshot()\n"
|
|
643
|
+
"del _ovito_mcp_screenshot\n"
|
|
644
|
+
)
|
|
645
|
+
result = execute(code, timeout=15.0)
|
|
646
|
+
shots = re.findall(r"^SHOT\t([^\t]*)\t([A-Za-z0-9+/=]+)$", result, re.M)
|
|
647
|
+
if not shots:
|
|
648
|
+
raise RuntimeError(f"Screenshot failed: {result}")
|
|
649
|
+
images: list[str | Image] = []
|
|
650
|
+
for title, encoded in shots:
|
|
651
|
+
try:
|
|
652
|
+
png_bytes = base64.b64decode(encoded)
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
raise RuntimeError(
|
|
655
|
+
f"Screenshot decode failed: {exc}\nRaw output:\n{result}"
|
|
656
|
+
) from exc
|
|
657
|
+
images.append(f"Window: {title}" if title else "Window: (untitled)")
|
|
658
|
+
images.append(Image(data=png_bytes, format="png"))
|
|
659
|
+
return images
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@mcp.tool()
|
|
663
|
+
def list_pipelines() -> str:
|
|
664
|
+
"""List all pipelines currently in the OVITO scene.
|
|
665
|
+
|
|
666
|
+
Use this to inspect the current program state — call it to discover what data is
|
|
667
|
+
pipelines are currently in the scene. Returns each pipeline's integer hash (use this
|
|
668
|
+
as a stable key to refer to a specific pipeline in subsequent calls) and its display
|
|
669
|
+
name (object_title). Requires an active kernel with an OVITO scene loaded.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
A table of pipeline keys and names, or a message if the scene is empty.
|
|
673
|
+
"""
|
|
674
|
+
code = (
|
|
675
|
+
"import ovito\n"
|
|
676
|
+
"_pipelines = list(ovito.scene.pipelines)\n"
|
|
677
|
+
"if not _pipelines:\n"
|
|
678
|
+
" print('No pipelines in the scene.')\n"
|
|
679
|
+
"else:\n"
|
|
680
|
+
" print(f'{len(_pipelines)} pipeline(s) in scene:')\n"
|
|
681
|
+
" print('key | name')\n"
|
|
682
|
+
" for _p in _pipelines:\n"
|
|
683
|
+
" print(f'{hash(_p)} | {_p.object_title}')\n"
|
|
684
|
+
)
|
|
685
|
+
return execute(code)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@mcp.tool()
|
|
689
|
+
def list_modifiers(pipeline_key: str) -> str:
|
|
690
|
+
"""List all modifiers in a specific OVITO pipeline.
|
|
691
|
+
|
|
692
|
+
Use this to inspect the current program state — call it to see which modifiers are
|
|
693
|
+
applied to a pipeline before modifying or reordering them. Obtain pipeline_key from
|
|
694
|
+
list_pipelines() first.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
pipeline_key: The integer hash of the pipeline as returned by list_pipelines().
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
A table of modifier keys, types, and names, or an error if the pipeline is not found.
|
|
701
|
+
"""
|
|
702
|
+
code = (
|
|
703
|
+
"import ovito\n"
|
|
704
|
+
f"_target_key = {pipeline_key}\n"
|
|
705
|
+
"_found = False\n"
|
|
706
|
+
"for _p in ovito.scene.pipelines:\n"
|
|
707
|
+
" if hash(_p) == _target_key:\n"
|
|
708
|
+
" _found = True\n"
|
|
709
|
+
" _mods = list(_p.modifiers)\n"
|
|
710
|
+
" if not _mods:\n"
|
|
711
|
+
" print('Pipeline has no modifiers.')\n"
|
|
712
|
+
" else:\n"
|
|
713
|
+
" print(f'{len(_mods)} modifier(s) in pipeline {_p.object_title!r}:')\n"
|
|
714
|
+
" print('key | type | name')\n"
|
|
715
|
+
" for _m in _mods:\n"
|
|
716
|
+
" print(f'{hash(_m)} | {type(_m).__name__} | {_m.object_title}')\n"
|
|
717
|
+
" break\n"
|
|
718
|
+
"if not _found:\n"
|
|
719
|
+
f" print('Error: No pipeline with key {pipeline_key} found. Call list_pipelines() to see available pipelines.')\n"
|
|
720
|
+
)
|
|
721
|
+
return execute(code)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@mcp.tool()
|
|
725
|
+
def inspect_pipeline_data(pipeline_key: int) -> str:
|
|
726
|
+
"""Return a structured overview of all data objects in a pipeline's current output.
|
|
727
|
+
|
|
728
|
+
Use this to inspect the current program state — call it to discover what data objects,
|
|
729
|
+
property containers, attributes, and simulation cell a pipeline currently produces
|
|
730
|
+
before writing analysis or export code. Obtain pipeline_key from list_pipelines().
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
pipeline_key: The integer hash of the pipeline as returned by list_pipelines().
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
Markdown tables listing global attributes, data objects (with numeric IDs and
|
|
737
|
+
types), property containers with their properties, and the simulation cell.
|
|
738
|
+
"""
|
|
739
|
+
code = f"""
|
|
740
|
+
import io
|
|
741
|
+
import ovito
|
|
742
|
+
import ovito.data
|
|
743
|
+
from ovito.data import DataCollection, DataObject
|
|
744
|
+
|
|
745
|
+
def _fmt(s):
|
|
746
|
+
return f'"{{s}}"' if isinstance(s, str) else repr(s)
|
|
747
|
+
|
|
748
|
+
def _data_collection_dir(data):
|
|
749
|
+
output = io.StringIO()
|
|
750
|
+
print("Global Attributes:\\n", file=output)
|
|
751
|
+
print("| Name | Value |", file=output)
|
|
752
|
+
print("|-----------|--------------|", file=output)
|
|
753
|
+
for attr in data.attributes:
|
|
754
|
+
print(f"| {{attr}} | {{_fmt(data.attributes[attr])}} |", file=output)
|
|
755
|
+
containers = []
|
|
756
|
+
def print_data_object(obj, path_prefix=""):
|
|
757
|
+
if isinstance(obj, ovito.data.AttributeDataObject):
|
|
758
|
+
return
|
|
759
|
+
print(f"| {{hash(obj)}} | {{type(obj).__name__}} | {{_fmt(path_prefix + obj.identifier)}} |", file=output)
|
|
760
|
+
if isinstance(obj, ovito.data.SurfaceMesh):
|
|
761
|
+
for subobj in [obj.vertices, obj.faces, obj.regions]:
|
|
762
|
+
print_data_object(subobj, path_prefix=obj.identifier + '/')
|
|
763
|
+
if isinstance(obj, ovito.data.PropertyContainer):
|
|
764
|
+
containers.append((obj, path_prefix + obj.identifier))
|
|
765
|
+
print("\\nData Objects:\\n", file=output)
|
|
766
|
+
print("| Numeric ID | Type | Identifier |", file=output)
|
|
767
|
+
print("|------------|------|------------|", file=output)
|
|
768
|
+
for obj in data.objects:
|
|
769
|
+
print_data_object(obj)
|
|
770
|
+
print("\\nProperty Containers:\\n", file=output)
|
|
771
|
+
for container, path in containers:
|
|
772
|
+
print(f"\\nNumeric ID: {{hash(container)}} - Path: {{_fmt(path)}} - Type: {{type(container).__name__}} - Element count: {{container.count}}\\n", file=output)
|
|
773
|
+
print("| Property name | Dtype | Vector components |", file=output)
|
|
774
|
+
print("|---------------|-----------|-------------------|", file=output)
|
|
775
|
+
for prop in container.properties:
|
|
776
|
+
cnames = prop.component_names if len(prop.component_names) > 0 else ''
|
|
777
|
+
print(f"| {{prop.identifier}} | {{prop.dtype}} | {{cnames}} |", file=output)
|
|
778
|
+
if data.cell:
|
|
779
|
+
print("\\nSimulation cell:\\n", file=output)
|
|
780
|
+
print(f" Vector a: {{data.cell[:,0]}}", file=output)
|
|
781
|
+
print(f" Vector b: {{data.cell[:,1]}}", file=output)
|
|
782
|
+
print(f" Vector c: {{data.cell[:,2]}}", file=output)
|
|
783
|
+
print(f" Origin: {{data.cell[:,3]}}", file=output)
|
|
784
|
+
print(f" PBC: {{data.cell.pbc}}", file=output)
|
|
785
|
+
return output.getvalue()
|
|
786
|
+
|
|
787
|
+
_target_key = {pipeline_key}
|
|
788
|
+
_found = False
|
|
789
|
+
for _p in ovito.scene.pipelines:
|
|
790
|
+
if hash(_p) == _target_key:
|
|
791
|
+
_found = True
|
|
792
|
+
_data = _p.compute()
|
|
793
|
+
print(_data_collection_dir(_data))
|
|
794
|
+
break
|
|
795
|
+
if not _found:
|
|
796
|
+
print(f"Error: No pipeline with key {pipeline_key} found. Call list_pipelines() to see available pipelines.")
|
|
797
|
+
"""
|
|
798
|
+
return execute(code)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@mcp.tool()
|
|
802
|
+
def list_pipeline_status() -> str:
|
|
803
|
+
"""Report the status of every pipeline, modifier, and visual element in the scene.
|
|
804
|
+
|
|
805
|
+
Surfaces the same information as the yellow/red warning and error triangles shown in
|
|
806
|
+
the OVITO GUI next to a pipeline, modifier, or visual element. Use this to discover
|
|
807
|
+
and diagnose pipeline errors or warnings — it is the first thing to check when the
|
|
808
|
+
GUI shows a triangle indicator, and before deciding whether restart_kernel() is
|
|
809
|
+
necessary.
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
A pretty-printed structure per pipeline: its nodes (pipeline source and
|
|
813
|
+
modifiers, each with type, id, title, modifier_type, modifier_id, status_code,
|
|
814
|
+
status_text, and enabled state) and its visual elements (title, type,
|
|
815
|
+
status_code, status_text, enabled, id).
|
|
816
|
+
"""
|
|
817
|
+
code = (
|
|
818
|
+
"import pprint\n"
|
|
819
|
+
"import ovito\n"
|
|
820
|
+
"from ovito.pipeline import ModificationNode\n"
|
|
821
|
+
"for pipeline in ovito.scene.pipelines:\n"
|
|
822
|
+
" pipeline_status = {\n"
|
|
823
|
+
" 'pipeline_id': hash(pipeline),\n"
|
|
824
|
+
" 'pipeline_title': pipeline.object_title,\n"
|
|
825
|
+
" 'nodes': [],\n"
|
|
826
|
+
" 'visual_elements': [],\n"
|
|
827
|
+
" }\n"
|
|
828
|
+
" node = pipeline.head\n"
|
|
829
|
+
" while node is not None:\n"
|
|
830
|
+
" pipeline_status['nodes'].append(\n"
|
|
831
|
+
" {\n"
|
|
832
|
+
" 'type': type(node).__name__,\n"
|
|
833
|
+
" 'id': hash(node),\n"
|
|
834
|
+
" 'title': node.object_title,\n"
|
|
835
|
+
" 'modifier_type': type(node.modifier).__name__ if isinstance(node, ModificationNode) else None,\n"
|
|
836
|
+
" 'modifier_id': hash(node.modifier) if isinstance(node, ModificationNode) else None,\n"
|
|
837
|
+
" 'status_code': node.status.type.name,\n"
|
|
838
|
+
" 'status_text': node.status.text,\n"
|
|
839
|
+
" 'enabled': node.modifier.enabled if isinstance(node, ModificationNode) else True,\n"
|
|
840
|
+
" }\n"
|
|
841
|
+
" )\n"
|
|
842
|
+
" node = node.input if isinstance(node, ModificationNode) else None\n"
|
|
843
|
+
" for vis in pipeline.vis_elements:\n"
|
|
844
|
+
" pipeline_status['visual_elements'].append(\n"
|
|
845
|
+
" {\n"
|
|
846
|
+
" 'title': vis.object_title,\n"
|
|
847
|
+
" 'type': type(vis).__name__,\n"
|
|
848
|
+
" 'status_code': vis.status.type.name,\n"
|
|
849
|
+
" 'status_text': vis.status.text,\n"
|
|
850
|
+
" 'enabled': vis.enabled,\n"
|
|
851
|
+
" 'id': hash(vis),\n"
|
|
852
|
+
" }\n"
|
|
853
|
+
" )\n"
|
|
854
|
+
" pprint.pprint(pipeline_status)\n"
|
|
855
|
+
)
|
|
856
|
+
return execute(code)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@mcp.tool()
|
|
860
|
+
def get_timeline_info() -> str:
|
|
861
|
+
"""Return the current frame and frame range of the OVITO scene timeline.
|
|
862
|
+
|
|
863
|
+
Use this to inspect the current program state — call it to learn the animation extent
|
|
864
|
+
and which frame is currently active.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
The current frame, first frame, and last frame as reported by ovito.scene.anim.
|
|
868
|
+
"""
|
|
869
|
+
code = (
|
|
870
|
+
"import ovito\n"
|
|
871
|
+
"_anim = ovito.scene.anim\n"
|
|
872
|
+
"print('current_frame | first_frame | last_frame')\n"
|
|
873
|
+
"print(f'{_anim.current_frame} | {_anim.first_frame} | {_anim.last_frame}')\n"
|
|
874
|
+
)
|
|
875
|
+
return execute(code)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@mcp.tool()
|
|
879
|
+
def set_current_frame(frame: int) -> str:
|
|
880
|
+
"""Set the current animation frame in the OVITO scene. Always use this tool to navigate
|
|
881
|
+
the timeline — do NOT set ovito.scene.anim.current_frame directly in execute().
|
|
882
|
+
|
|
883
|
+
This is the canonical way to change the active frame. It keeps the GUI viewport in sync
|
|
884
|
+
and ensures all pipelines recompute consistently. Calling pipeline.compute(frame) alone
|
|
885
|
+
does not advance the scene timeline and will leave the GUI out of step.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
frame: The frame number to set as the current frame.
|
|
889
|
+
|
|
890
|
+
Returns:
|
|
891
|
+
Confirmation of the new current frame.
|
|
892
|
+
"""
|
|
893
|
+
code = (
|
|
894
|
+
"import ovito\n"
|
|
895
|
+
f"ovito.scene.anim.current_frame = {frame}\n"
|
|
896
|
+
f"print(f'Current frame set to {frame}.')\n"
|
|
897
|
+
)
|
|
898
|
+
return execute(code)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _ovito_file_from_kernel() -> str | None:
|
|
902
|
+
"""Ask an already-connected kernel for ``ovito.__file__``, or None if unavailable.
|
|
903
|
+
|
|
904
|
+
Preferred over spawning OVITO_EXE in a subprocess when a kernel is already active
|
|
905
|
+
(self-started or externally connected via connect_kernel()): it reuses the live
|
|
906
|
+
session's own 'ovito' import instead of launching a second OVITO process, and works
|
|
907
|
+
even when OVITO_EXE isn't set (e.g. after connect_kernel() to an externally-started
|
|
908
|
+
kernel).
|
|
909
|
+
"""
|
|
910
|
+
if _session.kc is None:
|
|
911
|
+
return None
|
|
912
|
+
kc = _session.kc
|
|
913
|
+
try:
|
|
914
|
+
msg_id = kc.execute("import ovito; print(ovito.__file__)")
|
|
915
|
+
stdout: list[str] = []
|
|
916
|
+
while True:
|
|
917
|
+
try:
|
|
918
|
+
msg = kc.get_iopub_msg(timeout=15.0)
|
|
919
|
+
except queue.Empty:
|
|
920
|
+
return None
|
|
921
|
+
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
922
|
+
continue
|
|
923
|
+
mtype = msg["msg_type"]
|
|
924
|
+
content = msg["content"]
|
|
925
|
+
if mtype == "status" and content.get("execution_state") == "idle":
|
|
926
|
+
break
|
|
927
|
+
if mtype == "stream" and content.get("name") == "stdout":
|
|
928
|
+
stdout.append(content.get("text", ""))
|
|
929
|
+
elif mtype == "error":
|
|
930
|
+
return None
|
|
931
|
+
except Exception:
|
|
932
|
+
return None
|
|
933
|
+
text = "".join(stdout).strip()
|
|
934
|
+
return text or None
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _ovito_file_from_subprocess() -> str | None:
|
|
938
|
+
"""Run a one-off ``import ovito; print(ovito.__file__)`` under OVITO_EXE.
|
|
939
|
+
|
|
940
|
+
Fallback used when no kernel is currently connected (see _ovito_file_from_kernel()).
|
|
941
|
+
"""
|
|
942
|
+
if not OVITO_EXE:
|
|
943
|
+
return None
|
|
944
|
+
try:
|
|
945
|
+
result = subprocess.run(
|
|
946
|
+
[OVITO_EXE, "--nogui", "--exec", "import ovito; print(ovito.__file__)"],
|
|
947
|
+
capture_output=True,
|
|
948
|
+
text=True,
|
|
949
|
+
timeout=30,
|
|
950
|
+
)
|
|
951
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
952
|
+
return None
|
|
953
|
+
if result.returncode != 0:
|
|
954
|
+
return None
|
|
955
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
956
|
+
return lines[-1].strip() if lines else None
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _discover_rag_db() -> str | None:
|
|
960
|
+
"""Find rag.sqlite3 next to the active 'ovito' installation.
|
|
961
|
+
|
|
962
|
+
This is the normal way the documentation database is located — OVITO Pro ships it
|
|
963
|
+
inside the 'ovito' package, so no configuration is needed. Asks the connected
|
|
964
|
+
kernel for ``ovito.__file__`` (see _ovito_file_from_kernel()), which works without
|
|
965
|
+
OVITO_EXE and is why the standard connect_kernel() flow needs no setup. Only if no
|
|
966
|
+
kernel is connected does it fall back to spawning OVITO_EXE (see
|
|
967
|
+
_ovito_file_from_subprocess()).
|
|
968
|
+
|
|
969
|
+
Looks for ``rag.sqlite3`` next to the resolved package directory (e.g. alongside
|
|
970
|
+
``.../site-packages/ovito/__init__.py``).
|
|
971
|
+
"""
|
|
972
|
+
ovito_file = _ovito_file_from_kernel() or _ovito_file_from_subprocess()
|
|
973
|
+
if not ovito_file:
|
|
974
|
+
return None
|
|
975
|
+
candidate = Path(ovito_file).parent / "rag.sqlite3"
|
|
976
|
+
return str(candidate) if candidate.is_file() else None
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _docs_index() -> rag.DocsIndex:
|
|
980
|
+
"""Lazily construct (and cache) the documentation index.
|
|
981
|
+
|
|
982
|
+
On failure the cache is deliberately left unset, so a later call retries. This
|
|
983
|
+
matters: agents routinely search the documentation *before* calling
|
|
984
|
+
connect_kernel(), and at that point there is no kernel to ask for ovito.__file__.
|
|
985
|
+
Caching that failure would permanently disable the doc tools for the session.
|
|
986
|
+
"""
|
|
987
|
+
global _docs_index_instance
|
|
988
|
+
if _docs_index_instance is None:
|
|
989
|
+
db_path = RAG_DB or _discover_rag_db()
|
|
990
|
+
if not db_path:
|
|
991
|
+
raise ValueError(
|
|
992
|
+
"Could not locate the OVITO documentation database. It ships with OVITO "
|
|
993
|
+
"Pro and is found automatically next to the 'ovito' package, but that "
|
|
994
|
+
"requires an active kernel: call connect_kernel(<connection_file>) "
|
|
995
|
+
"first (or start_kernel(), which needs OVITO_EXE), then retry. To use a "
|
|
996
|
+
"different database, set the OVITO_RAG_DB environment variable."
|
|
997
|
+
)
|
|
998
|
+
_docs_index_instance = rag.DocsIndex(db_path)
|
|
999
|
+
return _docs_index_instance
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
class _RagError(Exception):
|
|
1003
|
+
"""Carries a message that's safe to return to the LLM as-is."""
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _call_rag(fn):
|
|
1007
|
+
"""Run ``fn(index)`` behind stdout-redirect + the standard error handling.
|
|
1008
|
+
|
|
1009
|
+
sentence-transformers prints during model load; redirect to stderr so it can never
|
|
1010
|
+
corrupt the stdio JSON-RPC stream. Raises _RagError with an LLM-facing message on
|
|
1011
|
+
any failure.
|
|
1012
|
+
"""
|
|
1013
|
+
import contextlib
|
|
1014
|
+
import sys
|
|
1015
|
+
|
|
1016
|
+
try:
|
|
1017
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
1018
|
+
return fn(_docs_index())
|
|
1019
|
+
except (ValueError, RuntimeError) as exc:
|
|
1020
|
+
# Raised for a missing/undiscoverable database or an empty index.
|
|
1021
|
+
raise _RagError(str(exc)) from exc
|
|
1022
|
+
except Exception as exc: # noqa: BLE001 - surface any retrieval failure to the LLM
|
|
1023
|
+
raise _RagError(f"RAG operation failed: {exc}") from exc
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _format_rag_hits(results: list[dict], query: str, noun: str) -> str:
|
|
1027
|
+
"""Render a list of search() / search_docs() / search_symbols() hits as text."""
|
|
1028
|
+
if not results:
|
|
1029
|
+
return f'No OVITO {noun} found for: "{query}"'
|
|
1030
|
+
|
|
1031
|
+
out = [f'Top {len(results)} OVITO {noun} result(s) for: "{query}"\n']
|
|
1032
|
+
for i, r in enumerate(results, 1):
|
|
1033
|
+
header = f"[{i}] score {r['score']:.3f}"
|
|
1034
|
+
if r.get("source"):
|
|
1035
|
+
header += f" — {r['source']}"
|
|
1036
|
+
if r.get("full_page_id") is not None:
|
|
1037
|
+
header += f" (page id {r['full_page_id']})"
|
|
1038
|
+
if r.get("doc_string_id") is not None:
|
|
1039
|
+
header += f" (docstring id {r['doc_string_id']})"
|
|
1040
|
+
out.append(header)
|
|
1041
|
+
if r.get("symbol"):
|
|
1042
|
+
out.append(
|
|
1043
|
+
f" symbol: {r['symbol']} ({r.get('symbol_type') or ''})".rstrip()
|
|
1044
|
+
)
|
|
1045
|
+
if r.get("section"):
|
|
1046
|
+
out.append(f" section: {r['section']}")
|
|
1047
|
+
text = r["text"]
|
|
1048
|
+
out.append("")
|
|
1049
|
+
out.append(text)
|
|
1050
|
+
out.append("-" * 60)
|
|
1051
|
+
return "\n".join(out)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@mcp.tool()
|
|
1055
|
+
def search_ovito_docs(query: str, top_k: int = 5) -> str:
|
|
1056
|
+
"""Search the OVITO documentation knowledge base (user manual + Python API reference).
|
|
1057
|
+
|
|
1058
|
+
A local RAG index over the OVITO user manual, the Python interface class docs, and
|
|
1059
|
+
the API stub signatures. Use it to look up how OVITO features work, the names and
|
|
1060
|
+
signatures of modifiers / pipeline objects / data classes and their properties and
|
|
1061
|
+
methods, and example usage — ideally *before* writing or debugging code with
|
|
1062
|
+
execute(). This is reference retrieval only; it does not run code.
|
|
1063
|
+
|
|
1064
|
+
Searches both the manual and the API symbols together. Use search_ovito_manual()
|
|
1065
|
+
or search_ovito_api_symbols() instead if you only want one or the other. Manual
|
|
1066
|
+
hits are resolved to their complete source page (not just the matching chunk),
|
|
1067
|
+
and API symbol hits include their full docstring.
|
|
1068
|
+
|
|
1069
|
+
Args:
|
|
1070
|
+
query: Natural-language question or keywords, e.g. "how to apply a slice
|
|
1071
|
+
modifier", "SliceModifier parameters", "compute coordination number",
|
|
1072
|
+
"export to LAMMPS dump file".
|
|
1073
|
+
top_k: Number of documentation results to return (default 5).
|
|
1074
|
+
|
|
1075
|
+
Returns:
|
|
1076
|
+
The most relevant full manual pages / API symbols ranked by similarity, each
|
|
1077
|
+
with its qualified name and kind (for API symbols). Returns guidance instead
|
|
1078
|
+
if the knowledge base is unavailable or empty.
|
|
1079
|
+
"""
|
|
1080
|
+
try:
|
|
1081
|
+
results = _call_rag(lambda backend: backend.search(query, top_k=top_k))
|
|
1082
|
+
except _RagError as exc:
|
|
1083
|
+
return str(exc)
|
|
1084
|
+
return _format_rag_hits(results, query, "documentation")
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
@mcp.tool()
|
|
1088
|
+
def search_ovito_manual(query: str, top_k: int = 5) -> str:
|
|
1089
|
+
"""Search only the OVITO user manual (markdown documentation), skipping API symbols.
|
|
1090
|
+
|
|
1091
|
+
Narrower than search_ovito_docs() — use this when you want conceptual/how-to
|
|
1092
|
+
manual content only and don't want Python API stub signatures mixed in. Each
|
|
1093
|
+
hit is resolved to its complete source page (not just the matching chunk),
|
|
1094
|
+
so you get the full page automatically — full_page_id is still included for
|
|
1095
|
+
reference (e.g. to fetch it again directly via get_ovito_manual_page()).
|
|
1096
|
+
|
|
1097
|
+
Args:
|
|
1098
|
+
query: Natural-language question or keywords, e.g. "how to apply a slice
|
|
1099
|
+
modifier", "export to LAMMPS dump file".
|
|
1100
|
+
top_k: Number of manual snippets to return (default 5).
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
The most relevant manual excerpts ranked by similarity. Returns guidance
|
|
1104
|
+
instead if unavailable or empty.
|
|
1105
|
+
"""
|
|
1106
|
+
try:
|
|
1107
|
+
results = _call_rag(lambda backend: backend.search_docs(query, top_k=top_k))
|
|
1108
|
+
except _RagError as exc:
|
|
1109
|
+
return str(exc)
|
|
1110
|
+
return _format_rag_hits(results, query, "manual")
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
@mcp.tool()
|
|
1114
|
+
def search_ovito_api_symbols(query: str, top_k: int = 5) -> str:
|
|
1115
|
+
"""Search only the OVITO Python API symbols (classes, methods, properties, functions).
|
|
1116
|
+
|
|
1117
|
+
Narrower than search_ovito_docs() — use this when you want API stub
|
|
1118
|
+
signatures/docstrings only and don't want user-manual prose mixed in.
|
|
1119
|
+
|
|
1120
|
+
Args:
|
|
1121
|
+
query: Natural-language question or keywords, e.g. "SliceModifier parameters",
|
|
1122
|
+
"coordination analysis modifier constructor".
|
|
1123
|
+
top_k: Number of symbols to return (default 5).
|
|
1124
|
+
|
|
1125
|
+
Returns:
|
|
1126
|
+
The most relevant API symbols ranked by similarity, each with its qualified
|
|
1127
|
+
name and kind. Returns guidance instead if unavailable or empty.
|
|
1128
|
+
"""
|
|
1129
|
+
try:
|
|
1130
|
+
results = _call_rag(lambda backend: backend.search_symbols(query, top_k=top_k))
|
|
1131
|
+
except _RagError as exc:
|
|
1132
|
+
return str(exc)
|
|
1133
|
+
return _format_rag_hits(results, query, "API symbol")
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
@mcp.tool()
|
|
1137
|
+
def get_ovito_manual_page(full_page_id: int) -> str:
|
|
1138
|
+
"""Fetch the complete text of an OVITO manual page by its full_page_id.
|
|
1139
|
+
|
|
1140
|
+
full_page_id values come from search_ovito_manual() results (each hit is tagged
|
|
1141
|
+
with the id of the page it was chunked from). Use this when a matching chunk
|
|
1142
|
+
isn't enough context and you need the entire source page.
|
|
1143
|
+
|
|
1144
|
+
Args:
|
|
1145
|
+
full_page_id: The manual page's database id, as returned by
|
|
1146
|
+
search_ovito_manual().
|
|
1147
|
+
|
|
1148
|
+
Returns:
|
|
1149
|
+
The full manual page text, or guidance if the id doesn't exist.
|
|
1150
|
+
"""
|
|
1151
|
+
try:
|
|
1152
|
+
page = _call_rag(lambda backend: backend.get_manual_page(full_page_id))
|
|
1153
|
+
except _RagError as exc:
|
|
1154
|
+
return str(exc)
|
|
1155
|
+
if page is None:
|
|
1156
|
+
return f"No manual page found with full_page_id={full_page_id}."
|
|
1157
|
+
return f"# page id {page['full_page_id']}\n\n{page['full_manual_page']}"
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
@mcp.tool()
|
|
1161
|
+
def get_ovito_docstring(
|
|
1162
|
+
doc_string_id: int | None = None, symbol: str | None = None
|
|
1163
|
+
) -> str:
|
|
1164
|
+
"""Fetch the complete docstring of an OVITO Python API symbol.
|
|
1165
|
+
|
|
1166
|
+
Provide exactly one of:
|
|
1167
|
+
- doc_string_id: the docstring's database id, if already known.
|
|
1168
|
+
- symbol: the exact, fully-qualified symbol name, e.g.
|
|
1169
|
+
"ovito.modifiers.SliceModifier" (the bare name "SliceModifier" will
|
|
1170
|
+
NOT match — this is an exact-match lookup, not a search). Use this
|
|
1171
|
+
when you already know the qualified name and want to skip a search
|
|
1172
|
+
round-trip.
|
|
1173
|
+
|
|
1174
|
+
Args:
|
|
1175
|
+
doc_string_id: The docstring's database id.
|
|
1176
|
+
symbol: The exact fully-qualified symbol name.
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
The full docstring text, or guidance if the lookup doesn't match anything
|
|
1180
|
+
or both/neither argument was given.
|
|
1181
|
+
"""
|
|
1182
|
+
if (doc_string_id is None) == (symbol is None):
|
|
1183
|
+
return "Provide exactly one of doc_string_id or symbol."
|
|
1184
|
+
|
|
1185
|
+
def run(backend):
|
|
1186
|
+
if symbol is not None:
|
|
1187
|
+
return backend.get_docstring_by_symbol(symbol)
|
|
1188
|
+
return backend.get_docstring(doc_string_id)
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
doc = _call_rag(run)
|
|
1192
|
+
except _RagError as exc:
|
|
1193
|
+
return str(exc)
|
|
1194
|
+
if doc is None:
|
|
1195
|
+
which = (
|
|
1196
|
+
f"symbol={symbol!r}"
|
|
1197
|
+
if symbol is not None
|
|
1198
|
+
else f"doc_string_id={doc_string_id}"
|
|
1199
|
+
)
|
|
1200
|
+
return f"No docstring found with {which}."
|
|
1201
|
+
return doc["doc_string"]
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@mcp.tool()
|
|
1205
|
+
def get_kernel_id() -> str:
|
|
1206
|
+
"""Return the UUID of the currently active kernel."""
|
|
1207
|
+
if _session.connection_file is None:
|
|
1208
|
+
return "No active kernel."
|
|
1209
|
+
return _id_from_path(_session.connection_file)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
@mcp.tool()
|
|
1213
|
+
def kernel_status() -> str:
|
|
1214
|
+
"""Report the active kernel: its id, ownership, and liveness."""
|
|
1215
|
+
if _session.kc is None or _session.connection_file is None:
|
|
1216
|
+
return "No active kernel. Call start_kernel() or connect_kernel()."
|
|
1217
|
+
kid = _id_from_path(_session.connection_file)
|
|
1218
|
+
if _session.km is not None:
|
|
1219
|
+
owned, alive = "started by this server", _session.km.is_alive()
|
|
1220
|
+
else:
|
|
1221
|
+
owned, alive = "connected (externally owned)", _session.kc.is_alive()
|
|
1222
|
+
return f"Active kernel: {kid}\nOwnership: {owned}\nAlive: {alive}"
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
@mcp.tool()
|
|
1226
|
+
def interrupt_kernel() -> str:
|
|
1227
|
+
"""Interrupt the active kernel (e.g. to stop a long-running execution)."""
|
|
1228
|
+
if _session.km is None:
|
|
1229
|
+
if _session.kc is not None:
|
|
1230
|
+
return (
|
|
1231
|
+
"The active kernel was connected via connect_kernel and is owned "
|
|
1232
|
+
"externally; interrupt it from the process that started it."
|
|
1233
|
+
)
|
|
1234
|
+
return "No active kernel."
|
|
1235
|
+
_session.km.interrupt_kernel()
|
|
1236
|
+
return f"Sent interrupt to kernel {_id_from_path(_session.km.connection_file)}."
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
@mcp.tool()
|
|
1240
|
+
def restart_kernel() -> str:
|
|
1241
|
+
"""Restart the active kernel (clears all state)."""
|
|
1242
|
+
if _session.km is None:
|
|
1243
|
+
if _session.kc is not None:
|
|
1244
|
+
return (
|
|
1245
|
+
"The active kernel was connected via connect_kernel and is owned "
|
|
1246
|
+
"externally; it cannot be restarted from here. Restart it from the "
|
|
1247
|
+
"process that started it, then call connect_kernel again."
|
|
1248
|
+
)
|
|
1249
|
+
return "No active kernel."
|
|
1250
|
+
_close_window_if_open()
|
|
1251
|
+
_session.km.restart_kernel(now=True)
|
|
1252
|
+
if _session.kc is not None:
|
|
1253
|
+
_session.kc.stop_channels()
|
|
1254
|
+
kc = _session.km.client()
|
|
1255
|
+
kc.start_channels()
|
|
1256
|
+
kc.wait_for_ready(timeout=60)
|
|
1257
|
+
_session.kc = kc
|
|
1258
|
+
return f"Restarted kernel {_id_from_path(_session.km.connection_file)}. All state cleared."
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
@mcp.tool()
|
|
1262
|
+
def shutdown_kernel() -> str:
|
|
1263
|
+
"""Shut down the active kernel, or detach from an externally-connected one.
|
|
1264
|
+
|
|
1265
|
+
A kernel this server started is shut down. A kernel attached via connect_kernel is
|
|
1266
|
+
owned externally, so it is left running and only this server's client is detached.
|
|
1267
|
+
"""
|
|
1268
|
+
if _session.kc is None or _session.connection_file is None:
|
|
1269
|
+
return "No active kernel."
|
|
1270
|
+
kid = _id_from_path(_session.connection_file)
|
|
1271
|
+
external = _session.km is None
|
|
1272
|
+
_teardown_current()
|
|
1273
|
+
if external:
|
|
1274
|
+
return f"Detached from externally-owned kernel {kid} (left running)."
|
|
1275
|
+
return f"Shut down kernel {kid}."
|