ovito-mcp 2026.1.dev3__tar.gz → 2026.1.dev4__tar.gz
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-2026.1.dev3 → ovito_mcp-2026.1.dev4}/.gitignore +1 -6
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/PKG-INFO +1 -1
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/ovito_mcp/__init__.py +444 -132
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/ovito_mcp/rag/__init__.py +8 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/ovito_mcp/rag/db.py +123 -3
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/pyproject.toml +1 -1
- ovito_mcp-2026.1.dev4/tests/conftest.py +65 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/tests/test_docs_index.py +17 -1
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/tests/test_docs_index_discovery.py +3 -4
- ovito_mcp-2026.1.dev4/tests/test_kernel_spec.py +59 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/tests/test_rag_db.py +89 -1
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/.gitlab-ci.yml +0 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/LICENSE +0 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/README.md +0 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/RELEASING.md +0 -0
- {ovito_mcp-2026.1.dev3 → ovito_mcp-2026.1.dev4}/ovito_mcp/__main__.py +0 -0
|
@@ -222,12 +222,7 @@ poetry.toml
|
|
|
222
222
|
pyrightconfig.json
|
|
223
223
|
|
|
224
224
|
### VisualStudioCode ###
|
|
225
|
-
.vscode
|
|
226
|
-
!.vscode/settings.json
|
|
227
|
-
!.vscode/tasks.json
|
|
228
|
-
!.vscode/launch.json
|
|
229
|
-
!.vscode/extensions.json
|
|
230
|
-
!.vscode/*.code-snippets
|
|
225
|
+
.vscode/
|
|
231
226
|
|
|
232
227
|
# Local History for Visual Studio Code
|
|
233
228
|
.history/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ovito-mcp
|
|
3
|
-
Version: 2026.1.
|
|
3
|
+
Version: 2026.1.dev4
|
|
4
4
|
Summary: MCP server for interactive work in OVITO Pro
|
|
5
5
|
Project-URL: Homepage, https://www.ovito.org
|
|
6
6
|
Project-URL: Documentation, https://docs.ovito.org/reference/data_inspector/coding_agent.html
|
|
@@ -25,7 +25,7 @@ import uuid
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from jupyter_client.blocking.client import BlockingKernelClient
|
|
28
|
-
from jupyter_client.kernelspec import NoSuchKernel
|
|
28
|
+
from jupyter_client.kernelspec import KernelSpec, KernelSpecManager, NoSuchKernel
|
|
29
29
|
from jupyter_client.manager import KernelManager
|
|
30
30
|
from jupyter_core.paths import jupyter_runtime_dir
|
|
31
31
|
from mcp.server.fastmcp import FastMCP, Image
|
|
@@ -101,7 +101,7 @@ def _require_active() -> BlockingKernelClient:
|
|
|
101
101
|
def _ensure_ipykernel_installed() -> str | None:
|
|
102
102
|
"""Install ``ipykernel`` into OVITO_EXE's Python environment if it is missing.
|
|
103
103
|
|
|
104
|
-
The
|
|
104
|
+
The kernel is launched as ``OVITO_EXE --kernel <connection_file>``, which imports
|
|
105
105
|
ipykernel internally — OVITO does not bundle it. OVITO_EXE has no real ``-m``
|
|
106
106
|
passthrough (it silently ignores the flag), so pip must be driven through its
|
|
107
107
|
internal API via ``--exec`` instead of ``OVITO_EXE -m pip install ...``.
|
|
@@ -149,6 +149,37 @@ def _ensure_ipykernel_installed() -> str | None:
|
|
|
149
149
|
return None
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def _ovito_kernel_spec() -> KernelSpec:
|
|
153
|
+
"""Build the OVITO kernel spec in memory, without touching the filesystem.
|
|
154
|
+
|
|
155
|
+
OVITO Pro's desktop installers do not register an ``ovito-pro`` kernelspec with
|
|
156
|
+
Jupyter — only the conda package does — and we would overwrite its argv with
|
|
157
|
+
OVITO_EXE anyway, since a registered spec cannot know where the user installed
|
|
158
|
+
OVITO. Synthesizing the spec drops that filesystem dependency entirely, so
|
|
159
|
+
start_kernel() works for every OVITO Pro edition and install location.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ValueError: If OVITO_EXE is not set.
|
|
163
|
+
"""
|
|
164
|
+
if not OVITO_EXE:
|
|
165
|
+
raise ValueError(_MISSING_OVITO_EXE)
|
|
166
|
+
return KernelSpec(
|
|
167
|
+
name=KERNEL_NAME,
|
|
168
|
+
argv=[OVITO_EXE, "--kernel", "{connection_file}"],
|
|
169
|
+
display_name="Python 3 + OVITO Pro (GUI)",
|
|
170
|
+
language="python",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _OvitoKernelSpecManager(KernelSpecManager):
|
|
175
|
+
"""Serves the synthesized OVITO spec instead of searching Jupyter's data dirs."""
|
|
176
|
+
|
|
177
|
+
def get_kernel_spec(self, kernel_name: str) -> KernelSpec:
|
|
178
|
+
if kernel_name != KERNEL_NAME:
|
|
179
|
+
raise NoSuchKernel(kernel_name)
|
|
180
|
+
return _ovito_kernel_spec()
|
|
181
|
+
|
|
182
|
+
|
|
152
183
|
def _teardown_current() -> None:
|
|
153
184
|
"""Tear down the active session before starting or connecting to another kernel.
|
|
154
185
|
|
|
@@ -309,9 +340,10 @@ def start_kernel() -> str:
|
|
|
309
340
|
and has supplied a connection file — that is the standard flow and needs no
|
|
310
341
|
configuration.
|
|
311
342
|
|
|
312
|
-
If a kernel is already running it is shut down first. The
|
|
313
|
-
|
|
314
|
-
|
|
343
|
+
If a kernel is already running it is shut down first. The launch command is built
|
|
344
|
+
directly from OVITO_EXE rather than looked up in Jupyter's data directories, so no
|
|
345
|
+
``ovito-pro`` kernelspec needs to be registered on the machine and the kernel runs
|
|
346
|
+
under OVITO regardless of PATH. Before launching, ``ipykernel`` is installed into
|
|
315
347
|
OVITO_EXE's Python environment if it isn't already present.
|
|
316
348
|
|
|
317
349
|
Returns:
|
|
@@ -324,28 +356,19 @@ def start_kernel() -> str:
|
|
|
324
356
|
return err
|
|
325
357
|
|
|
326
358
|
_teardown_current()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
359
|
+
# Use ZMQ IPC transport (Unix-domain socket files) so kernel messaging stays
|
|
360
|
+
# local and does not open a network port.
|
|
361
|
+
km = KernelManager(
|
|
362
|
+
kernel_name=KERNEL_NAME,
|
|
363
|
+
kernel_spec_manager=_OvitoKernelSpecManager(),
|
|
364
|
+
transport="ipc",
|
|
365
|
+
)
|
|
334
366
|
|
|
335
367
|
# Write the connection file into the Jupyter runtime dir with a standard
|
|
336
368
|
# kernel-<id>.json name, so the kernel shows up in `jupyter console --existing`.
|
|
337
369
|
os.makedirs(RUNTIME_DIR, exist_ok=True)
|
|
338
370
|
km.connection_file = os.path.join(RUNTIME_DIR, f"kernel-{uuid.uuid4()}.json")
|
|
339
371
|
|
|
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
372
|
km.start_kernel()
|
|
350
373
|
kc = km.client()
|
|
351
374
|
kc.start_channels()
|
|
@@ -663,61 +686,149 @@ def screenshot_window() -> list[str | Image]:
|
|
|
663
686
|
def list_pipelines() -> str:
|
|
664
687
|
"""List all pipelines currently in the OVITO scene.
|
|
665
688
|
|
|
666
|
-
Use this to inspect the current program state — call it to discover what data
|
|
667
|
-
pipelines are
|
|
668
|
-
|
|
669
|
-
|
|
689
|
+
Use this to inspect the current program state — call it to discover what data
|
|
690
|
+
pipelines are in the user-visible scene (`ovito.scene.pipelines` list). Requires an
|
|
691
|
+
active kernel.
|
|
692
|
+
|
|
693
|
+
One record per pipeline, reporting:
|
|
694
|
+
|
|
695
|
+
- `key`: integer identifier to pass to list_modifiers(), inspect_pipeline_data(), and
|
|
696
|
+
other pipeline-specific tools. Valid only within the current kernel session — keys
|
|
697
|
+
change after restart_kernel(), so re-list rather than reusing an old key.
|
|
698
|
+
- `index`: position in `ovito.scene.pipelines`, matching the top-to-bottom order of the
|
|
699
|
+
GUI pipeline selector. Use it to resolve references like "the second pipeline".
|
|
700
|
+
- `title`: the display name shown in the GUI (`object_title`). For a file-based source
|
|
701
|
+
this already embeds the bare filename and the importer type, e.g.
|
|
702
|
+
`dump.lammpstrj [LAMMPS Dump]`, but not the directory.
|
|
703
|
+
- `source`: the data source type plus its full source path(s), which disambiguates
|
|
704
|
+
pipelines whose titles are identical because they load same-named files from
|
|
705
|
+
different directories.
|
|
706
|
+
- `modifiers`: how many modifiers the pipeline has and their type names, so a separate
|
|
707
|
+
list_modifiers() call is only needed when the individual modifier keys are wanted.
|
|
708
|
+
- `status`: the worst status over the pipeline's source and modifier nodes — the same
|
|
709
|
+
condition the GUI's warning/error triangles indicate. Call list_pipeline_status()
|
|
710
|
+
for the per-node detail behind a Warning or Error.
|
|
711
|
+
|
|
712
|
+
The pipeline currently selected in the GUI is flagged, which is usually the one a user
|
|
713
|
+
means by "this pipeline" or "the current pipeline".
|
|
670
714
|
|
|
671
715
|
Returns:
|
|
672
|
-
|
|
716
|
+
One labeled record per pipeline, or a message if the scene is empty.
|
|
673
717
|
"""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
718
|
+
# Wrapped in a function so the iteration variables don't leak into the kernel
|
|
719
|
+
# namespace, where they would show up in the user's interactive session.
|
|
720
|
+
code = '''
|
|
721
|
+
def _ovito_mcp_list_pipelines():
|
|
722
|
+
import ovito
|
|
723
|
+
from ovito.pipeline import ModificationNode
|
|
724
|
+
|
|
725
|
+
pipelines = list(ovito.scene.pipelines)
|
|
726
|
+
if not pipelines:
|
|
727
|
+
print("No pipelines in the scene.")
|
|
728
|
+
return
|
|
729
|
+
selected = ovito.scene.selected_pipeline
|
|
730
|
+
print(f"{len(pipelines)} pipeline(s) in the scene.")
|
|
731
|
+
print("Keys are valid for the current kernel session only.")
|
|
732
|
+
for index, pipeline in enumerate(pipelines):
|
|
733
|
+
# Worst status over the node chain, mirroring the GUI's triangle indicators.
|
|
734
|
+
status = "OK"
|
|
735
|
+
node = pipeline.head
|
|
736
|
+
while node is not None:
|
|
737
|
+
node_status = node.status.type.name
|
|
738
|
+
if node_status == "Error":
|
|
739
|
+
status = "Error"
|
|
740
|
+
elif node_status == "Warning" and status != "Error":
|
|
741
|
+
status = "Warning"
|
|
742
|
+
node = node.input if isinstance(node, ModificationNode) else None
|
|
743
|
+
if status != "OK":
|
|
744
|
+
status += " (call list_pipeline_status() for details)"
|
|
745
|
+
|
|
746
|
+
# source_path is a str for a single file, a list for a multi-file sequence, and
|
|
747
|
+
# absent entirely for non-file sources such as StaticSource or PythonSource.
|
|
748
|
+
source = pipeline.source
|
|
749
|
+
source_desc = type(source).__name__
|
|
750
|
+
paths = getattr(source, "source_path", None)
|
|
751
|
+
if isinstance(paths, str):
|
|
752
|
+
paths = [paths] if paths else []
|
|
753
|
+
elif paths is None:
|
|
754
|
+
paths = []
|
|
755
|
+
else:
|
|
756
|
+
paths = list(paths)
|
|
757
|
+
if paths:
|
|
758
|
+
source_desc += " " + repr(paths[0])
|
|
759
|
+
if len(paths) > 1:
|
|
760
|
+
source_desc += f" (+{len(paths) - 1} more file(s))"
|
|
761
|
+
|
|
762
|
+
modifiers = list(pipeline.modifiers)
|
|
763
|
+
if modifiers:
|
|
764
|
+
names = [type(m).__name__ for m in modifiers[:10]]
|
|
765
|
+
if len(modifiers) > 10:
|
|
766
|
+
names.append("...")
|
|
767
|
+
mod_desc = f"{len(modifiers)} ({', '.join(names)})"
|
|
768
|
+
else:
|
|
769
|
+
mod_desc = "0"
|
|
770
|
+
|
|
771
|
+
marker = " <- currently selected in the GUI" if pipeline is selected else ""
|
|
772
|
+
print(f"\\n[{index}] key={hash(pipeline)}{marker}")
|
|
773
|
+
print(f" title: {pipeline.object_title!r}")
|
|
774
|
+
print(f" source: {source_desc}")
|
|
775
|
+
print(f" modifiers: {mod_desc}")
|
|
776
|
+
print(f" status: {status}")
|
|
777
|
+
|
|
778
|
+
_ovito_mcp_list_pipelines()
|
|
779
|
+
del _ovito_mcp_list_pipelines
|
|
780
|
+
'''
|
|
685
781
|
return execute(code)
|
|
686
782
|
|
|
687
783
|
|
|
688
784
|
@mcp.tool()
|
|
689
|
-
def list_modifiers(pipeline_key:
|
|
785
|
+
def list_modifiers(pipeline_key: int) -> str:
|
|
690
786
|
"""List all modifiers in a specific OVITO pipeline.
|
|
691
787
|
|
|
692
|
-
Use this to inspect
|
|
693
|
-
applied to a pipeline before modifying or reordering them
|
|
694
|
-
list_pipelines()
|
|
788
|
+
Use this to inspect a specific pipeline — call it to see which modifiers are
|
|
789
|
+
applied to a pipeline before modifying or reordering them, or to obtain a modifier's
|
|
790
|
+
key. list_pipelines() already reports each pipeline's modifier type names, so this
|
|
791
|
+
call is only needed when the individual keys or the enabled state matter.
|
|
792
|
+
|
|
793
|
+
Modifiers are listed in pipeline order, i.e. the order in which they are applied to
|
|
794
|
+
the data, which is bottom-to-top in the GUI pipeline editor.
|
|
695
795
|
|
|
696
796
|
Args:
|
|
697
|
-
pipeline_key: The integer
|
|
797
|
+
pipeline_key: The integer key of the pipeline as returned by list_pipelines().
|
|
698
798
|
|
|
699
799
|
Returns:
|
|
700
|
-
|
|
800
|
+
One labeled record per modifier, or an error if the pipeline is not found.
|
|
701
801
|
"""
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
"
|
|
719
|
-
|
|
720
|
-
)
|
|
802
|
+
# Wrapped in a function so the iteration variables don't leak into the kernel
|
|
803
|
+
# namespace. pipeline_key is typed int, so interpolating it into the source is safe.
|
|
804
|
+
code = f'''
|
|
805
|
+
def _ovito_mcp_list_modifiers():
|
|
806
|
+
import ovito
|
|
807
|
+
|
|
808
|
+
target_key = {pipeline_key}
|
|
809
|
+
for pipeline in ovito.scene.pipelines:
|
|
810
|
+
if hash(pipeline) == target_key:
|
|
811
|
+
break
|
|
812
|
+
else:
|
|
813
|
+
print(f"Error: No pipeline with key {{target_key}} found. "
|
|
814
|
+
"Call list_pipelines() to see the available pipelines.")
|
|
815
|
+
return
|
|
816
|
+
modifiers = list(pipeline.modifiers)
|
|
817
|
+
if not modifiers:
|
|
818
|
+
print(f"Pipeline {{pipeline.object_title!r}} has no modifiers.")
|
|
819
|
+
return
|
|
820
|
+
print(f"{{len(modifiers)}} modifier(s) in pipeline {{pipeline.object_title!r}}, "
|
|
821
|
+
"in the order they are applied:")
|
|
822
|
+
print("Keys are valid for the current kernel session only.")
|
|
823
|
+
for index, modifier in enumerate(modifiers):
|
|
824
|
+
disabled = " <- DISABLED, not applied to the data" if not modifier.enabled else ""
|
|
825
|
+
print(f"\\n[{{index}}] key={{hash(modifier)}}{{disabled}}")
|
|
826
|
+
print(f" type: {{type(modifier).__name__}}")
|
|
827
|
+
print(f" title: {{modifier.object_title!r}}")
|
|
828
|
+
|
|
829
|
+
_ovito_mcp_list_modifiers()
|
|
830
|
+
del _ovito_mcp_list_modifiers
|
|
831
|
+
'''
|
|
721
832
|
return execute(code)
|
|
722
833
|
|
|
723
834
|
|
|
@@ -730,7 +841,7 @@ def inspect_pipeline_data(pipeline_key: int) -> str:
|
|
|
730
841
|
before writing analysis or export code. Obtain pipeline_key from list_pipelines().
|
|
731
842
|
|
|
732
843
|
Args:
|
|
733
|
-
pipeline_key: The integer
|
|
844
|
+
pipeline_key: The integer key of the pipeline as returned by list_pipelines().
|
|
734
845
|
|
|
735
846
|
Returns:
|
|
736
847
|
Markdown tables listing global attributes, data objects (with numeric IDs and
|
|
@@ -809,69 +920,132 @@ def list_pipeline_status() -> str:
|
|
|
809
920
|
necessary.
|
|
810
921
|
|
|
811
922
|
Returns:
|
|
812
|
-
A pretty-printed structure per pipeline
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
923
|
+
A pretty-printed structure per pipeline, with `pipeline_key` and `pipeline_title`,
|
|
924
|
+
a `nodes` list, and a `visual_elements` list.
|
|
925
|
+
|
|
926
|
+
`nodes` holds the pipeline source followed by the modifiers in the order they are
|
|
927
|
+
applied — the same order list_modifiers() reports. Each entry has `node_type`,
|
|
928
|
+
`node_id`, `title`, `modifier_type`, `modifier_key`, `status_code`, `status_text`,
|
|
929
|
+
and `enabled`. Each visual element has `title`, `vis_element_type`,
|
|
930
|
+
`vis_element_id`, `status_code`, `status_text`, and `enabled`.
|
|
931
|
+
|
|
932
|
+
`pipeline_key` and `modifier_key` are the same keys list_pipelines() and
|
|
933
|
+
list_modifiers() report, and are accepted by the tools that take a key.
|
|
934
|
+
`node_id` and `vis_element_id` identify the internal pipeline node and the visual
|
|
935
|
+
element instead, are NOT interchangeable with those keys, and no tool accepts
|
|
936
|
+
them — they serve only to tell otherwise identical-looking entries apart.
|
|
816
937
|
"""
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
"
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
938
|
+
# Wrapped in a function so the iteration variables don't leak into the kernel
|
|
939
|
+
# namespace, where they would show up in the user's interactive session.
|
|
940
|
+
code = '''
|
|
941
|
+
def _ovito_mcp_list_pipeline_status():
|
|
942
|
+
import pprint
|
|
943
|
+
import ovito
|
|
944
|
+
from ovito.pipeline import ModificationNode
|
|
945
|
+
|
|
946
|
+
for pipeline in ovito.scene.pipelines:
|
|
947
|
+
pipeline_status = {
|
|
948
|
+
"pipeline_key": hash(pipeline),
|
|
949
|
+
"pipeline_title": pipeline.object_title,
|
|
950
|
+
"nodes": [],
|
|
951
|
+
"visual_elements": [],
|
|
952
|
+
}
|
|
953
|
+
node = pipeline.head
|
|
954
|
+
while node is not None:
|
|
955
|
+
is_modifier = isinstance(node, ModificationNode)
|
|
956
|
+
pipeline_status["nodes"].append(
|
|
957
|
+
{
|
|
958
|
+
"node_type": type(node).__name__,
|
|
959
|
+
"node_id": hash(node),
|
|
960
|
+
"title": node.object_title,
|
|
961
|
+
"modifier_type": type(node.modifier).__name__ if is_modifier else None,
|
|
962
|
+
"modifier_key": hash(node.modifier) if is_modifier else None,
|
|
963
|
+
"status_code": node.status.type.name,
|
|
964
|
+
"status_text": node.status.text,
|
|
965
|
+
"enabled": node.modifier.enabled if is_modifier else True,
|
|
966
|
+
}
|
|
967
|
+
)
|
|
968
|
+
node = node.input if is_modifier else None
|
|
969
|
+
# The chain is walked head-first, i.e. last modifier first. Reverse it so the
|
|
970
|
+
# source comes first and the modifiers follow in the order they are applied,
|
|
971
|
+
# matching list_modifiers() rather than contradicting it.
|
|
972
|
+
pipeline_status["nodes"].reverse()
|
|
973
|
+
for vis in pipeline.vis_elements:
|
|
974
|
+
pipeline_status["visual_elements"].append(
|
|
975
|
+
{
|
|
976
|
+
"title": vis.object_title,
|
|
977
|
+
"vis_element_type": type(vis).__name__,
|
|
978
|
+
"vis_element_id": hash(vis),
|
|
979
|
+
"status_code": vis.status.type.name,
|
|
980
|
+
"status_text": vis.status.text,
|
|
981
|
+
"enabled": vis.enabled,
|
|
982
|
+
}
|
|
983
|
+
)
|
|
984
|
+
pprint.pprint(pipeline_status, sort_dicts=False)
|
|
985
|
+
|
|
986
|
+
_ovito_mcp_list_pipeline_status()
|
|
987
|
+
del _ovito_mcp_list_pipeline_status
|
|
988
|
+
'''
|
|
856
989
|
return execute(code)
|
|
857
990
|
|
|
858
991
|
|
|
859
992
|
@mcp.tool()
|
|
860
993
|
def get_timeline_info() -> str:
|
|
861
|
-
"""Return the
|
|
994
|
+
"""Return the state of the OVITO scene timeline and per-pipeline trajectory lengths.
|
|
995
|
+
|
|
996
|
+
Use this to inspect the current program state — call it to learn the animation extent,
|
|
997
|
+
which frame is currently active, and how many trajectory frames each pipeline actually
|
|
998
|
+
has, before iterating over frames or exporting an animation.
|
|
862
999
|
|
|
863
|
-
|
|
864
|
-
and
|
|
1000
|
+
Reports the animation interval (`first_frame`/`last_frame` of `ovito.scene.anim`), the
|
|
1001
|
+
`current_frame`, `frames_per_second`, and `auto_adjust_interval`, followed by the
|
|
1002
|
+
`num_frames` of every pipeline in the scene.
|
|
1003
|
+
|
|
1004
|
+
The animation interval is a scene-wide property, whereas `num_frames` is per pipeline,
|
|
1005
|
+
and the two need not agree. While `auto_adjust_interval` is enabled the interval tracks
|
|
1006
|
+
the longest pipeline automatically, but once it is disabled — or when pipelines of
|
|
1007
|
+
differing length are in the scene — a pipeline can be shorter than the interval. Asking
|
|
1008
|
+
such a pipeline for a frame beyond its own last one does not fail; it yields that
|
|
1009
|
+
pipeline's final frame.
|
|
865
1010
|
|
|
866
1011
|
Returns:
|
|
867
|
-
The
|
|
1012
|
+
The timeline state, then one line per pipeline with its trajectory length, or a
|
|
1013
|
+
note if the scene has no pipelines.
|
|
868
1014
|
"""
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1015
|
+
# Wrapped in a function so the iteration variables don't leak into the kernel
|
|
1016
|
+
# namespace, where they would show up in the user's interactive session.
|
|
1017
|
+
code = '''
|
|
1018
|
+
def _ovito_mcp_get_timeline_info():
|
|
1019
|
+
import ovito
|
|
1020
|
+
|
|
1021
|
+
anim = ovito.scene.anim
|
|
1022
|
+
first, last = anim.first_frame, anim.last_frame
|
|
1023
|
+
print(f"current_frame: {anim.current_frame}")
|
|
1024
|
+
print(f"animation interval: frames {first} to {last} ({last - first + 1} frame(s))")
|
|
1025
|
+
print(f"frames_per_second: {anim.frames_per_second} (movie export and playback rate)")
|
|
1026
|
+
print(f"auto_adjust_interval: {anim.auto_adjust_interval}"
|
|
1027
|
+
+ (" (interval follows the longest pipeline automatically)"
|
|
1028
|
+
if anim.auto_adjust_interval else " (interval is set manually)"))
|
|
1029
|
+
|
|
1030
|
+
pipelines = list(ovito.scene.pipelines)
|
|
1031
|
+
if not pipelines:
|
|
1032
|
+
print("\\nNo pipelines in the scene, so the interval reflects no trajectory data.")
|
|
1033
|
+
return
|
|
1034
|
+
print("\\nTrajectory length per pipeline:")
|
|
1035
|
+
for index, pipeline in enumerate(pipelines):
|
|
1036
|
+
num_frames = pipeline.num_frames
|
|
1037
|
+
note = ""
|
|
1038
|
+
if num_frames == 1:
|
|
1039
|
+
note = " <- static, single frame"
|
|
1040
|
+
elif num_frames - 1 < last:
|
|
1041
|
+
note = (f" <- ends at frame {num_frames - 1}, before the interval does; "
|
|
1042
|
+
f"frames {num_frames} to {last} repeat its last frame")
|
|
1043
|
+
print(f" [{index}] key={hash(pipeline)} num_frames={num_frames} "
|
|
1044
|
+
f"{pipeline.object_title!r}{note}")
|
|
1045
|
+
|
|
1046
|
+
_ovito_mcp_get_timeline_info()
|
|
1047
|
+
del _ovito_mcp_get_timeline_info
|
|
1048
|
+
'''
|
|
875
1049
|
return execute(code)
|
|
876
1050
|
|
|
877
1051
|
|
|
@@ -884,38 +1058,132 @@ def set_current_frame(frame: int) -> str:
|
|
|
884
1058
|
and ensures all pipelines recompute consistently. Calling pipeline.compute(frame) alone
|
|
885
1059
|
does not advance the scene timeline and will leave the GUI out of step.
|
|
886
1060
|
|
|
1061
|
+
The frame must lie within the animation interval reported by get_timeline_info(). A
|
|
1062
|
+
frame outside it is rejected rather than applied: OVITO does not clamp `current_frame`
|
|
1063
|
+
to the interval, so an out-of-range value would leave the time slider past the end of
|
|
1064
|
+
the timeline while pipelines keep returning their last available frame — data that
|
|
1065
|
+
looks valid but does not correspond to the requested frame. Use
|
|
1066
|
+
set_animation_interval() first if the interval itself is meant to be longer.
|
|
1067
|
+
|
|
887
1068
|
Args:
|
|
888
|
-
frame: The frame number to set as the current frame.
|
|
1069
|
+
frame: The frame number to set as the current frame. Zero-based, and must be
|
|
1070
|
+
within the current animation interval.
|
|
889
1071
|
|
|
890
1072
|
Returns:
|
|
891
|
-
Confirmation
|
|
1073
|
+
Confirmation reporting the frame actually in effect, or an error naming the valid
|
|
1074
|
+
range if the frame lies outside the animation interval.
|
|
892
1075
|
"""
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1076
|
+
# Read the value back rather than echoing the requested frame, so the confirmation
|
|
1077
|
+
# reflects what the scene actually holds.
|
|
1078
|
+
code = f'''
|
|
1079
|
+
def _ovito_mcp_set_current_frame():
|
|
1080
|
+
import ovito
|
|
1081
|
+
|
|
1082
|
+
anim = ovito.scene.anim
|
|
1083
|
+
frame = {frame}
|
|
1084
|
+
if frame < anim.first_frame or frame > anim.last_frame:
|
|
1085
|
+
print(f"Error: Frame {{frame}} is outside the animation interval "
|
|
1086
|
+
f"[{{anim.first_frame}}, {{anim.last_frame}}]. Current frame left unchanged "
|
|
1087
|
+
f"at {{anim.current_frame}}. Call get_timeline_info() for the trajectory "
|
|
1088
|
+
"lengths, or set_animation_interval() to extend the interval.")
|
|
1089
|
+
return
|
|
1090
|
+
anim.current_frame = frame
|
|
1091
|
+
print(f"Current frame is now {{anim.current_frame}} "
|
|
1092
|
+
f"(interval: {{anim.first_frame}} to {{anim.last_frame}}).")
|
|
1093
|
+
|
|
1094
|
+
_ovito_mcp_set_current_frame()
|
|
1095
|
+
del _ovito_mcp_set_current_frame
|
|
1096
|
+
'''
|
|
898
1097
|
return execute(code)
|
|
899
1098
|
|
|
900
1099
|
|
|
901
|
-
|
|
902
|
-
|
|
1100
|
+
@mcp.tool()
|
|
1101
|
+
def set_animation_interval(
|
|
1102
|
+
first_frame: int | None = None,
|
|
1103
|
+
last_frame: int | None = None,
|
|
1104
|
+
auto_adjust: bool = False,
|
|
1105
|
+
) -> str:
|
|
1106
|
+
"""Set the OVITO scene's animation interval, or restore automatic adjustment of it.
|
|
903
1107
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1108
|
+
The animation interval is the frame range the timeline spans — what the GUI's
|
|
1109
|
+
*Animation settings* dialog calls the *custom animation interval*. Use this tool rather
|
|
1110
|
+
than assigning to `ovito.scene.anim.first_frame`/`last_frame` in execute(), because a
|
|
1111
|
+
manually assigned interval is silently overwritten again while `auto_adjust_interval`
|
|
1112
|
+
is enabled; this tool disables that option as part of setting a custom interval.
|
|
1113
|
+
|
|
1114
|
+
Normally the interval is adjusted automatically to span the longest trajectory in the
|
|
1115
|
+
scene, and no call is needed. A custom interval is for animating over a dataset whose
|
|
1116
|
+
length does not drive the timeline — most commonly a camera or turntable animation over
|
|
1117
|
+
a static, single-frame dataset, where the interval must be extended manually to give
|
|
1118
|
+
the animation somewhere to run.
|
|
1119
|
+
|
|
1120
|
+
Note that extending the interval past a pipeline's own `num_frames` does not create new
|
|
1121
|
+
trajectory data: the pipeline keeps yielding its last frame for the added range.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
first_frame: New first frame of the interval, or None to leave it unchanged.
|
|
1125
|
+
last_frame: New last frame of the interval, or None to leave it unchanged.
|
|
1126
|
+
auto_adjust: If True, re-enable automatic adjustment of the interval to the
|
|
1127
|
+
trajectory data in the scene, discarding any custom interval. The
|
|
1128
|
+
first_frame/last_frame arguments are then ignored, since OVITO recomputes both.
|
|
1129
|
+
|
|
1130
|
+
Returns:
|
|
1131
|
+
The resulting animation interval, or an error if the requested range is invalid.
|
|
1132
|
+
"""
|
|
1133
|
+
code = f'''
|
|
1134
|
+
def _ovito_mcp_set_animation_interval():
|
|
1135
|
+
import ovito
|
|
1136
|
+
|
|
1137
|
+
anim = ovito.scene.anim
|
|
1138
|
+
first, last = {first_frame!r}, {last_frame!r}
|
|
1139
|
+
if {auto_adjust!r}:
|
|
1140
|
+
anim.auto_adjust_interval = True
|
|
1141
|
+
print(f"Automatic interval adjustment re-enabled. Animation interval is now "
|
|
1142
|
+
f"{{anim.first_frame}} to {{anim.last_frame}}, current frame "
|
|
1143
|
+
f"{{anim.current_frame}}.")
|
|
1144
|
+
return
|
|
1145
|
+
if first is None:
|
|
1146
|
+
first = anim.first_frame
|
|
1147
|
+
if last is None:
|
|
1148
|
+
last = anim.last_frame
|
|
1149
|
+
if first > last:
|
|
1150
|
+
print(f"Error: first_frame ({{first}}) must not exceed last_frame ({{last}}). "
|
|
1151
|
+
"Animation interval left unchanged.")
|
|
1152
|
+
return
|
|
1153
|
+
# Must be turned off first: while it is on, OVITO recomputes the interval from the
|
|
1154
|
+
# scene's trajectories and the values assigned below would not survive.
|
|
1155
|
+
anim.auto_adjust_interval = False
|
|
1156
|
+
anim.first_frame = first
|
|
1157
|
+
anim.last_frame = last
|
|
1158
|
+
print(f"Animation interval set to {{anim.first_frame}} to {{anim.last_frame}} "
|
|
1159
|
+
f"({{anim.last_frame - anim.first_frame + 1}} frame(s)). "
|
|
1160
|
+
"Automatic adjustment is now disabled; call set_animation_interval("
|
|
1161
|
+
"auto_adjust=True) to restore it.")
|
|
1162
|
+
if anim.current_frame < anim.first_frame or anim.current_frame > anim.last_frame:
|
|
1163
|
+
print(f"Note: current frame {{anim.current_frame}} now lies outside the interval. "
|
|
1164
|
+
"Call set_current_frame() to move it back inside.")
|
|
1165
|
+
|
|
1166
|
+
_ovito_mcp_set_animation_interval()
|
|
1167
|
+
del _ovito_mcp_set_animation_interval
|
|
1168
|
+
'''
|
|
1169
|
+
return execute(code)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _kernel_stdout(code: str, timeout: float = 15.0) -> str | None:
|
|
1173
|
+
"""Run ``code`` in the connected kernel and return its captured stdout, or None.
|
|
1174
|
+
|
|
1175
|
+
Returns None when no kernel is connected, the kernel raises, or it doesn't go
|
|
1176
|
+
idle within ``timeout``.
|
|
909
1177
|
"""
|
|
910
1178
|
if _session.kc is None:
|
|
911
1179
|
return None
|
|
912
1180
|
kc = _session.kc
|
|
913
1181
|
try:
|
|
914
|
-
msg_id = kc.execute(
|
|
1182
|
+
msg_id = kc.execute(code)
|
|
915
1183
|
stdout: list[str] = []
|
|
916
1184
|
while True:
|
|
917
1185
|
try:
|
|
918
|
-
msg = kc.get_iopub_msg(timeout=
|
|
1186
|
+
msg = kc.get_iopub_msg(timeout=timeout)
|
|
919
1187
|
except queue.Empty:
|
|
920
1188
|
return None
|
|
921
1189
|
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
@@ -934,6 +1202,47 @@ def _ovito_file_from_kernel() -> str | None:
|
|
|
934
1202
|
return text or None
|
|
935
1203
|
|
|
936
1204
|
|
|
1205
|
+
def _ovito_file_from_kernel() -> str | None:
|
|
1206
|
+
"""Ask an already-connected kernel for ``ovito.__file__``, or None if unavailable.
|
|
1207
|
+
|
|
1208
|
+
Preferred over spawning OVITO_EXE in a subprocess when a kernel is already active
|
|
1209
|
+
(self-started or externally connected via connect_kernel()): it reuses the live
|
|
1210
|
+
session's own 'ovito' import instead of launching a second OVITO process, and works
|
|
1211
|
+
even when OVITO_EXE isn't set (e.g. after connect_kernel() to an externally-started
|
|
1212
|
+
kernel).
|
|
1213
|
+
"""
|
|
1214
|
+
return _kernel_stdout("import ovito; print(ovito.__file__)")
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _ovito_version_from_kernel() -> str | None:
|
|
1218
|
+
"""Ask an already-connected kernel for ``ovito.version_string``, or None.
|
|
1219
|
+
|
|
1220
|
+
Used only to enrich user-facing messages (e.g. a database-incompatibility
|
|
1221
|
+
error) with the connected OVITO Pro version; never required for correctness.
|
|
1222
|
+
"""
|
|
1223
|
+
out = _kernel_stdout("import ovito; print(ovito.version_string)")
|
|
1224
|
+
return out.splitlines()[-1].strip() if out else None
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _version_context() -> str:
|
|
1228
|
+
"""A short ' (Detected: OVITO Pro X, ovito-mcp Y.)' suffix for error messages.
|
|
1229
|
+
|
|
1230
|
+
Best-effort: omits either component it cannot determine so the caller can
|
|
1231
|
+
append it unconditionally.
|
|
1232
|
+
"""
|
|
1233
|
+
parts: list[str] = []
|
|
1234
|
+
ovito_version = _ovito_version_from_kernel()
|
|
1235
|
+
if ovito_version:
|
|
1236
|
+
parts.append(f"OVITO Pro {ovito_version}")
|
|
1237
|
+
try:
|
|
1238
|
+
from importlib.metadata import version as _pkg_version
|
|
1239
|
+
|
|
1240
|
+
parts.append(f"ovito-mcp {_pkg_version('ovito-mcp')}")
|
|
1241
|
+
except Exception: # noqa: BLE001 - version context is purely informational
|
|
1242
|
+
pass
|
|
1243
|
+
return f" (Detected: {', '.join(parts)}.)" if parts else ""
|
|
1244
|
+
|
|
1245
|
+
|
|
937
1246
|
def _ovito_file_from_subprocess() -> str | None:
|
|
938
1247
|
"""Run a one-off ``import ovito; print(ovito.__file__)`` under OVITO_EXE.
|
|
939
1248
|
|
|
@@ -1016,6 +1325,9 @@ def _call_rag(fn):
|
|
|
1016
1325
|
try:
|
|
1017
1326
|
with contextlib.redirect_stdout(sys.stderr):
|
|
1018
1327
|
return fn(_docs_index())
|
|
1328
|
+
except rag.db.IncompatibleDatabaseError as exc:
|
|
1329
|
+
# Append the detected product versions so the user knows what they have.
|
|
1330
|
+
raise _RagError(f"{exc}{_version_context()}") from exc
|
|
1019
1331
|
except (ValueError, RuntimeError) as exc:
|
|
1020
1332
|
# Raised for a missing/undiscoverable database or an empty index.
|
|
1021
1333
|
raise _RagError(str(exc)) from exc
|
|
@@ -28,6 +28,14 @@ class DocsIndex:
|
|
|
28
28
|
if not resolved.exists():
|
|
29
29
|
raise ValueError(f"SQLite RAG database {resolved} does not exist.")
|
|
30
30
|
self._db_path = str(resolved)
|
|
31
|
+
# Reject an incompatible database once, up front, with a clear message —
|
|
32
|
+
# before any search runs — rather than letting a schema/embedding-model
|
|
33
|
+
# mismatch surface later as an empty result or a garbage similarity score.
|
|
34
|
+
conn = ragdb.connect(self._db_path)
|
|
35
|
+
try:
|
|
36
|
+
ragdb.check_compatibility(conn)
|
|
37
|
+
finally:
|
|
38
|
+
conn.close()
|
|
31
39
|
self._chunk_matrix: ragdb.Matrix | None = None
|
|
32
40
|
self._symbol_matrix: ragdb.Matrix | None = None
|
|
33
41
|
self._summary_matrix: dict[str, ragdb.Matrix] = {}
|
|
@@ -91,6 +91,23 @@ os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "5")
|
|
|
91
91
|
EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
92
92
|
EMBED_DIM = 384
|
|
93
93
|
|
|
94
|
+
# ── RAG database format compatibility ─────────────────────────────────────────
|
|
95
|
+
# The RAG database (rag.sqlite3) is produced by OVITO Pro and read here; the two
|
|
96
|
+
# components are released independently. OVITO Pro stamps the database with a
|
|
97
|
+
# single integer format version (rag_meta.schema_version, mirrored into
|
|
98
|
+
# PRAGMA user_version); this server accepts a database whose version lies in the
|
|
99
|
+
# inclusive range below, and otherwise refuses it with a directional
|
|
100
|
+
# "update OVITO Pro" / "update ovito-mcp" message.
|
|
101
|
+
#
|
|
102
|
+
# RAG_SCHEMA_VERSION - the format this build natively targets.
|
|
103
|
+
# MIN_READABLE_.. - oldest database format this build can still read.
|
|
104
|
+
# MAX_READABLE_.. - newest database format this build understands.
|
|
105
|
+
# Raise MAX_READABLE_SCHEMA_VERSION (and, for a non-backward-compatible change,
|
|
106
|
+
# MIN_READABLE_SCHEMA_VERSION) whenever OVITO Pro bumps its RAG_SCHEMA_VERSION.
|
|
107
|
+
RAG_SCHEMA_VERSION = 1
|
|
108
|
+
MIN_READABLE_SCHEMA_VERSION = 1
|
|
109
|
+
MAX_READABLE_SCHEMA_VERSION = 1
|
|
110
|
+
|
|
94
111
|
# Cross-encoder used to re-rank the bi-encoder candidate pool (stage 2).
|
|
95
112
|
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
96
113
|
# How many candidates semantic_search retrieves before re-ranking. Tunable:
|
|
@@ -99,6 +116,11 @@ RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
|
99
116
|
RERANK_CANDIDATES = 25
|
|
100
117
|
|
|
101
118
|
SCHEMA = """
|
|
119
|
+
CREATE TABLE IF NOT EXISTS rag_meta (
|
|
120
|
+
key TEXT PRIMARY KEY,
|
|
121
|
+
value TEXT NOT NULL
|
|
122
|
+
);
|
|
123
|
+
|
|
102
124
|
CREATE TABLE IF NOT EXISTS manual_pages (
|
|
103
125
|
full_page_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
126
|
full_manual_page TEXT NOT NULL,
|
|
@@ -158,15 +180,113 @@ CREATE INDEX IF NOT EXISTS idx_anchors_full_page_id ON anchors(full_page_id);
|
|
|
158
180
|
# ── Connection / schema ───────────────────────────────────────────────────────
|
|
159
181
|
|
|
160
182
|
|
|
183
|
+
class IncompatibleDatabaseError(RuntimeError):
|
|
184
|
+
"""Raised when the RAG database's format is not compatible with this server.
|
|
185
|
+
|
|
186
|
+
A RuntimeError subclass (like ModelsUnavailableError) so _call_rag() surfaces
|
|
187
|
+
its already-user-facing message verbatim to the agent, and so an incompatible
|
|
188
|
+
database disables only the documentation-search tools while every other MCP
|
|
189
|
+
tool keeps working.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
161
193
|
def connect(db_path: str | Path) -> sqlite3.Connection:
|
|
162
|
-
"""Open the RAG database
|
|
163
|
-
|
|
194
|
+
"""Open the RAG database read-only.
|
|
195
|
+
|
|
196
|
+
This is a pure reader: OVITO Pro produces the database, we only query it.
|
|
197
|
+
Opening read-only (and never running the schema DDL) guarantees we cannot
|
|
198
|
+
mutate a database — in particular we never silently create empty tables when
|
|
199
|
+
pointed at a foreign, empty, or future-format file, which would mask a real
|
|
200
|
+
incompatibility. The SCHEMA constant above is retained only as documentation
|
|
201
|
+
of the format this server expects. Compatibility is validated once up front
|
|
202
|
+
via check_compatibility() (see DocsIndex.__init__).
|
|
203
|
+
"""
|
|
204
|
+
uri = f"{Path(db_path).resolve().as_uri()}?mode=ro"
|
|
205
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
164
206
|
conn.row_factory = sqlite3.Row
|
|
165
207
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
166
|
-
conn.executescript(SCHEMA)
|
|
167
208
|
return conn
|
|
168
209
|
|
|
169
210
|
|
|
211
|
+
def read_db_meta(conn: sqlite3.Connection) -> dict[str, str]:
|
|
212
|
+
"""Return the rag_meta key/value pairs as a dict (empty if the table is absent)."""
|
|
213
|
+
has_table = conn.execute(
|
|
214
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'rag_meta'"
|
|
215
|
+
).fetchone()
|
|
216
|
+
if has_table is None:
|
|
217
|
+
return {}
|
|
218
|
+
return {r["key"]: r["value"] for r in conn.execute("SELECT key, value FROM rag_meta")}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_schema_version(conn: sqlite3.Connection) -> int | None:
|
|
222
|
+
"""Return the database's stamped format version, or None if unstamped.
|
|
223
|
+
|
|
224
|
+
Prefers rag_meta.schema_version; falls back to PRAGMA user_version. A value of
|
|
225
|
+
0 / absent both mean "no marker" and are reported as None so the caller can
|
|
226
|
+
reject a foreign or pre-versioning database rather than defaulting it.
|
|
227
|
+
"""
|
|
228
|
+
meta = read_db_meta(conn)
|
|
229
|
+
raw = meta.get("schema_version")
|
|
230
|
+
if raw is not None:
|
|
231
|
+
try:
|
|
232
|
+
return int(raw)
|
|
233
|
+
except ValueError:
|
|
234
|
+
return None
|
|
235
|
+
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
236
|
+
return int(user_version) if user_version else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def check_compatibility(conn: sqlite3.Connection) -> None:
|
|
240
|
+
"""Validate the open database's format against this server's supported range.
|
|
241
|
+
|
|
242
|
+
Raises IncompatibleDatabaseError with a directional, user-facing message when
|
|
243
|
+
the database is unstamped (foreign/corrupt/pre-versioning) or its version is
|
|
244
|
+
outside [MIN_READABLE_SCHEMA_VERSION, MAX_READABLE_SCHEMA_VERSION]. Also
|
|
245
|
+
asserts the embedding model/dimensionality match, catching a "same schema,
|
|
246
|
+
different vectors" mismatch that the version integer alone might miss.
|
|
247
|
+
"""
|
|
248
|
+
version = read_schema_version(conn)
|
|
249
|
+
if version is None:
|
|
250
|
+
raise IncompatibleDatabaseError(
|
|
251
|
+
"This does not look like an OVITO documentation database, or it was "
|
|
252
|
+
"produced by an OVITO Pro version too old to carry a format marker. "
|
|
253
|
+
"Documentation search is unavailable. Update OVITO Pro to a version "
|
|
254
|
+
"that ships a versioned RAG database."
|
|
255
|
+
)
|
|
256
|
+
if version > MAX_READABLE_SCHEMA_VERSION:
|
|
257
|
+
raise IncompatibleDatabaseError(
|
|
258
|
+
f"The OVITO documentation database uses format version {version}, which "
|
|
259
|
+
f"is newer than this ovito-mcp server understands (it supports up to "
|
|
260
|
+
f"format version {MAX_READABLE_SCHEMA_VERSION}). Documentation search is "
|
|
261
|
+
f"unavailable. Update the ovito-mcp server to match your OVITO Pro "
|
|
262
|
+
f"installation."
|
|
263
|
+
)
|
|
264
|
+
if version < MIN_READABLE_SCHEMA_VERSION:
|
|
265
|
+
raise IncompatibleDatabaseError(
|
|
266
|
+
f"The OVITO documentation database uses format version {version}, which "
|
|
267
|
+
f"is older than this ovito-mcp server supports (it requires at least "
|
|
268
|
+
f"format version {MIN_READABLE_SCHEMA_VERSION}). Documentation search is "
|
|
269
|
+
f"unavailable. Update OVITO Pro to match your ovito-mcp server."
|
|
270
|
+
)
|
|
271
|
+
meta = read_db_meta(conn)
|
|
272
|
+
db_model = meta.get("embedding_model")
|
|
273
|
+
if db_model is not None and db_model != EMBED_MODEL:
|
|
274
|
+
raise IncompatibleDatabaseError(
|
|
275
|
+
f"The OVITO documentation database was built with embedding model "
|
|
276
|
+
f"'{db_model}', but this ovito-mcp server searches with '{EMBED_MODEL}'. "
|
|
277
|
+
f"Documentation search is unavailable. Update the ovito-mcp server or "
|
|
278
|
+
f"OVITO Pro so both use the same embedding model."
|
|
279
|
+
)
|
|
280
|
+
db_dim = meta.get("embedding_dim")
|
|
281
|
+
if db_dim is not None and db_dim != str(EMBED_DIM):
|
|
282
|
+
raise IncompatibleDatabaseError(
|
|
283
|
+
f"The OVITO documentation database stores {db_dim}-dimensional "
|
|
284
|
+
f"embeddings, but this ovito-mcp server expects {EMBED_DIM}. "
|
|
285
|
+
f"Documentation search is unavailable. Update the ovito-mcp server or "
|
|
286
|
+
f"OVITO Pro so both agree on the embedding dimensionality."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
170
290
|
# ── Embedding ──────────────────────────────────────────────────────────────────
|
|
171
291
|
|
|
172
292
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared test fixtures for the ovito_mcp test suite.
|
|
2
|
+
|
|
3
|
+
The production ``db.connect()`` opens the RAG database read-only and never
|
|
4
|
+
creates the schema — OVITO Pro is the sole producer. Tests therefore need their
|
|
5
|
+
own writable builder to construct fixture databases, which is what
|
|
6
|
+
``make_rag_db`` provides. It also lets a test stamp an arbitrary format version /
|
|
7
|
+
embedding model to exercise the compatibility checks (see test_rag_db.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from ovito_mcp.rag import db
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_rag_db(
|
|
21
|
+
path: str | Path,
|
|
22
|
+
*,
|
|
23
|
+
schema_version: int | None = None,
|
|
24
|
+
embedding_model: str | None = None,
|
|
25
|
+
embedding_dim: int | None = None,
|
|
26
|
+
stamp: bool = True,
|
|
27
|
+
) -> sqlite3.Connection:
|
|
28
|
+
"""Create a writable RAG database with the schema and (optionally) the marker.
|
|
29
|
+
|
|
30
|
+
Returns an open writable connection. With ``stamp=True`` (the default) the
|
|
31
|
+
compatibility marker is written using the current constants unless overridden;
|
|
32
|
+
pass ``stamp=False`` to simulate an unstamped (foreign / pre-versioning)
|
|
33
|
+
database, or override ``schema_version`` / ``embedding_*`` to simulate an
|
|
34
|
+
incompatible one.
|
|
35
|
+
"""
|
|
36
|
+
if schema_version is None:
|
|
37
|
+
schema_version = db.RAG_SCHEMA_VERSION
|
|
38
|
+
if embedding_model is None:
|
|
39
|
+
embedding_model = db.EMBED_MODEL
|
|
40
|
+
if embedding_dim is None:
|
|
41
|
+
embedding_dim = db.EMBED_DIM
|
|
42
|
+
|
|
43
|
+
conn = sqlite3.connect(str(path))
|
|
44
|
+
conn.row_factory = sqlite3.Row
|
|
45
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
46
|
+
conn.executescript(db.SCHEMA)
|
|
47
|
+
if stamp:
|
|
48
|
+
conn.execute(f"PRAGMA user_version = {int(schema_version)}")
|
|
49
|
+
conn.executemany(
|
|
50
|
+
"INSERT INTO rag_meta (key, value) VALUES (?, ?) "
|
|
51
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
52
|
+
[
|
|
53
|
+
("schema_version", str(schema_version)),
|
|
54
|
+
("embedding_model", embedding_model),
|
|
55
|
+
("embedding_dim", str(embedding_dim)),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
conn.commit()
|
|
59
|
+
return conn
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def make_rag_db():
|
|
64
|
+
"""Factory fixture returning build_rag_db (see its docstring)."""
|
|
65
|
+
return build_rag_db
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import numpy as np
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
|
+
from conftest import build_rag_db
|
|
12
13
|
from ovito_mcp import rag
|
|
13
14
|
from ovito_mcp.rag import db
|
|
14
15
|
|
|
@@ -28,7 +29,7 @@ def stub_cross_encoder(monkeypatch):
|
|
|
28
29
|
@pytest.fixture
|
|
29
30
|
def db_path(tmp_path):
|
|
30
31
|
path = tmp_path / "rag.sqlite3"
|
|
31
|
-
conn =
|
|
32
|
+
conn = build_rag_db(path)
|
|
32
33
|
try:
|
|
33
34
|
page_id = conn.execute(
|
|
34
35
|
"INSERT INTO manual_pages (full_manual_page) VALUES (?)",
|
|
@@ -95,6 +96,21 @@ def test_docs_index_rejects_missing_db_path(tmp_path):
|
|
|
95
96
|
rag.DocsIndex(tmp_path / "does-not-exist.sqlite3")
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
def test_docs_index_rejects_incompatible_db_at_construction(tmp_path):
|
|
100
|
+
"""An incompatible database is rejected up front, before any search runs."""
|
|
101
|
+
path = tmp_path / "rag.sqlite3"
|
|
102
|
+
build_rag_db(path, schema_version=db.MAX_READABLE_SCHEMA_VERSION + 1).close()
|
|
103
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="newer than this ovito-mcp"):
|
|
104
|
+
rag.DocsIndex(path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_docs_index_rejects_unstamped_db_at_construction(tmp_path):
|
|
108
|
+
path = tmp_path / "rag.sqlite3"
|
|
109
|
+
build_rag_db(path, stamp=False).close()
|
|
110
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="does not look like an OVITO"):
|
|
111
|
+
rag.DocsIndex(path)
|
|
112
|
+
|
|
113
|
+
|
|
98
114
|
def test_search_docs_caches_embedding_matrix_across_calls(db_path, monkeypatch):
|
|
99
115
|
monkeypatch.setattr(db, "embed_one", lambda text: np.array([1, 0], dtype=np.float32))
|
|
100
116
|
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
13
|
import ovito_mcp
|
|
14
|
+
from conftest import build_rag_db
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@pytest.fixture(autouse=True)
|
|
@@ -23,9 +24,8 @@ def clear_index_cache(monkeypatch):
|
|
|
23
24
|
def test_failed_discovery_is_not_cached(monkeypatch, tmp_path):
|
|
24
25
|
"""A failure before a kernel exists must not disable the doc tools for good."""
|
|
25
26
|
db_file = tmp_path / "rag.sqlite3"
|
|
26
|
-
from ovito_mcp.rag import db as ragdb
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
build_rag_db(db_file).close() # create an empty but valid, stamped database
|
|
29
29
|
|
|
30
30
|
attempts = []
|
|
31
31
|
|
|
@@ -51,9 +51,8 @@ def test_failed_discovery_is_not_cached(monkeypatch, tmp_path):
|
|
|
51
51
|
|
|
52
52
|
def test_rag_db_env_override_wins_over_discovery(monkeypatch, tmp_path):
|
|
53
53
|
db_file = tmp_path / "custom.sqlite3"
|
|
54
|
-
from ovito_mcp.rag import db as ragdb
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
build_rag_db(db_file).close()
|
|
57
56
|
|
|
58
57
|
monkeypatch.setattr(ovito_mcp, "RAG_DB", str(db_file))
|
|
59
58
|
monkeypatch.setattr(
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for the synthesized OVITO kernel spec.
|
|
2
|
+
|
|
3
|
+
start_kernel() must not depend on an ``ovito-pro`` kernelspec being registered in
|
|
4
|
+
any Jupyter data directory: OVITO Pro's desktop installers never register one, and
|
|
5
|
+
the launch command has to point at whatever OVITO_EXE says anyway. These tests pin
|
|
6
|
+
that down without launching OVITO — they only exercise the spec plumbing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from jupyter_client.kernelspec import NoSuchKernel
|
|
13
|
+
from jupyter_client.manager import KernelManager
|
|
14
|
+
|
|
15
|
+
import ovito_mcp
|
|
16
|
+
|
|
17
|
+
DUMMY_EXE = "/nonexistent/Ovito.app/Contents/MacOS/ovito"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def ovito_exe(monkeypatch: pytest.MonkeyPatch) -> str:
|
|
22
|
+
"""Point the module-level OVITO_EXE at a path that need not exist."""
|
|
23
|
+
monkeypatch.setattr(ovito_mcp, "OVITO_EXE", DUMMY_EXE)
|
|
24
|
+
return DUMMY_EXE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_spec_launches_ovito_exe(ovito_exe: str) -> None:
|
|
28
|
+
spec = ovito_mcp._OvitoKernelSpecManager().get_kernel_spec(ovito_mcp.KERNEL_NAME)
|
|
29
|
+
assert spec.argv == [ovito_exe, "--kernel", "{connection_file}"]
|
|
30
|
+
assert spec.language == "python"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_unknown_kernel_name_rejected(ovito_exe: str) -> None:
|
|
34
|
+
with pytest.raises(NoSuchKernel):
|
|
35
|
+
ovito_mcp._OvitoKernelSpecManager().get_kernel_spec("something-else")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_kernel_manager_resolves_spec_without_filesystem(ovito_exe: str) -> None:
|
|
39
|
+
"""KernelManager must obtain the spec from us, not from Jupyter's data dirs.
|
|
40
|
+
|
|
41
|
+
This is the regression guard for the actual bug, and it holds either way round:
|
|
42
|
+
on a machine with no 'ovito-pro' kernelspec a filesystem lookup would raise
|
|
43
|
+
NoSuchKernel, and on a machine that happens to have a stale one registered the
|
|
44
|
+
lookup would yield its argv (a bare 'ovito') instead of DUMMY_EXE. Only the
|
|
45
|
+
injected manager satisfies the assertion below.
|
|
46
|
+
"""
|
|
47
|
+
km = KernelManager(
|
|
48
|
+
kernel_name=ovito_mcp.KERNEL_NAME,
|
|
49
|
+
kernel_spec_manager=ovito_mcp._OvitoKernelSpecManager(),
|
|
50
|
+
transport="ipc",
|
|
51
|
+
)
|
|
52
|
+
assert km.kernel_spec is not None
|
|
53
|
+
assert km.kernel_spec.argv == [ovito_exe, "--kernel", "{connection_file}"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_missing_ovito_exe_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
57
|
+
monkeypatch.setattr(ovito_mcp, "OVITO_EXE", None)
|
|
58
|
+
with pytest.raises(ValueError):
|
|
59
|
+
ovito_mcp._ovito_kernel_spec()
|
|
@@ -6,9 +6,12 @@ load the real sentence-transformers model.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
9
11
|
import numpy as np
|
|
10
12
|
import pytest
|
|
11
13
|
|
|
14
|
+
from conftest import build_rag_db
|
|
12
15
|
from ovito_mcp.rag import db
|
|
13
16
|
|
|
14
17
|
|
|
@@ -37,7 +40,7 @@ def stub_cross_encoder(monkeypatch):
|
|
|
37
40
|
|
|
38
41
|
@pytest.fixture
|
|
39
42
|
def conn(tmp_path):
|
|
40
|
-
c =
|
|
43
|
+
c = build_rag_db(tmp_path / "rag.sqlite3")
|
|
41
44
|
yield c
|
|
42
45
|
c.close()
|
|
43
46
|
|
|
@@ -174,3 +177,88 @@ def test_stats_counts_rows(conn):
|
|
|
174
177
|
assert counts["python_symbols"] == 1
|
|
175
178
|
assert counts["python_docstrings"] == 0
|
|
176
179
|
assert counts["summaries"] == 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Format-compatibility marker ────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_read_meta_and_schema_version_roundtrip(conn):
|
|
186
|
+
meta = db.read_db_meta(conn)
|
|
187
|
+
assert meta["schema_version"] == str(db.RAG_SCHEMA_VERSION)
|
|
188
|
+
assert meta["embedding_model"] == db.EMBED_MODEL
|
|
189
|
+
assert meta["embedding_dim"] == str(db.EMBED_DIM)
|
|
190
|
+
assert db.read_schema_version(conn) == db.RAG_SCHEMA_VERSION
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_read_schema_version_falls_back_to_user_version(tmp_path):
|
|
194
|
+
conn = build_rag_db(tmp_path / "rag.sqlite3", stamp=False)
|
|
195
|
+
try:
|
|
196
|
+
assert db.read_schema_version(conn) is None # no marker at all
|
|
197
|
+
conn.execute("PRAGMA user_version = 1")
|
|
198
|
+
assert db.read_schema_version(conn) == 1
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_check_compatibility_accepts_current(conn):
|
|
204
|
+
db.check_compatibility(conn) # must not raise
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_check_compatibility_rejects_unstamped(tmp_path):
|
|
208
|
+
conn = build_rag_db(tmp_path / "rag.sqlite3", stamp=False)
|
|
209
|
+
try:
|
|
210
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="does not look like an OVITO"):
|
|
211
|
+
db.check_compatibility(conn)
|
|
212
|
+
finally:
|
|
213
|
+
conn.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_check_compatibility_rejects_too_new(tmp_path):
|
|
217
|
+
conn = build_rag_db(
|
|
218
|
+
tmp_path / "rag.sqlite3", schema_version=db.MAX_READABLE_SCHEMA_VERSION + 1
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="newer than this ovito-mcp"):
|
|
222
|
+
db.check_compatibility(conn)
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_check_compatibility_rejects_too_old(tmp_path, monkeypatch):
|
|
228
|
+
monkeypatch.setattr(db, "MIN_READABLE_SCHEMA_VERSION", db.RAG_SCHEMA_VERSION + 1)
|
|
229
|
+
monkeypatch.setattr(db, "MAX_READABLE_SCHEMA_VERSION", db.RAG_SCHEMA_VERSION + 1)
|
|
230
|
+
conn = build_rag_db(tmp_path / "rag.sqlite3", schema_version=db.RAG_SCHEMA_VERSION)
|
|
231
|
+
try:
|
|
232
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="older than this ovito-mcp"):
|
|
233
|
+
db.check_compatibility(conn)
|
|
234
|
+
finally:
|
|
235
|
+
conn.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_check_compatibility_rejects_wrong_embedding_model(tmp_path):
|
|
239
|
+
conn = build_rag_db(tmp_path / "rag.sqlite3", embedding_model="some/other-model")
|
|
240
|
+
try:
|
|
241
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="embedding model"):
|
|
242
|
+
db.check_compatibility(conn)
|
|
243
|
+
finally:
|
|
244
|
+
conn.close()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_check_compatibility_rejects_wrong_embedding_dim(tmp_path):
|
|
248
|
+
conn = build_rag_db(tmp_path / "rag.sqlite3", embedding_dim=128)
|
|
249
|
+
try:
|
|
250
|
+
with pytest.raises(db.IncompatibleDatabaseError, match="dimensional"):
|
|
251
|
+
db.check_compatibility(conn)
|
|
252
|
+
finally:
|
|
253
|
+
conn.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_connect_opens_read_only(conn, tmp_path):
|
|
257
|
+
# A production connect() must refuse writes even to a valid database.
|
|
258
|
+
conn.close()
|
|
259
|
+
ro = db.connect(tmp_path / "rag.sqlite3")
|
|
260
|
+
try:
|
|
261
|
+
with pytest.raises(sqlite3.OperationalError):
|
|
262
|
+
ro.execute("INSERT INTO rag_meta (key, value) VALUES ('x', 'y')")
|
|
263
|
+
finally:
|
|
264
|
+
ro.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|