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 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}."