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.
@@ -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.dev3
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 kernelspec launches ``OVITO_EXE --kernel <connection_file>``, which imports
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 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
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
- 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
- )
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 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.
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
- A table of pipeline keys and names, or a message if the scene is empty.
716
+ One labeled record per pipeline, or a message if the scene is empty.
673
717
  """
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
- )
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: str) -> str:
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 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.
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 hash of the pipeline as returned by list_pipelines().
797
+ pipeline_key: The integer key of the pipeline as returned by list_pipelines().
698
798
 
699
799
  Returns:
700
- A table of modifier keys, types, and names, or an error if the pipeline is not found.
800
+ One labeled record per modifier, or an error if the pipeline is not found.
701
801
  """
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
- )
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 hash of the pipeline as returned by list_pipelines().
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: 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).
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
- 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
- )
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 current frame and frame range of the OVITO scene timeline.
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
- Use this to inspect the current program state — call it to learn the animation extent
864
- and which frame is currently active.
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 current frame, first frame, and last frame as reported by ovito.scene.anim.
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
- 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
- )
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 of the new current frame.
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
- 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
- )
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
- def _ovito_file_from_kernel() -> str | None:
902
- """Ask an already-connected kernel for ``ovito.__file__``, or None if unavailable.
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
- 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).
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("import ovito; print(ovito.__file__)")
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=15.0)
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, creating the schema on first use."""
163
- conn = sqlite3.connect(str(db_path))
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ovito-mcp"
7
- version = "2026.1.dev3"
7
+ version = "2026.1.dev4"
8
8
  description = "MCP server for interactive work in OVITO Pro"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 = db.connect(path)
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
- ragdb.connect(db_file).close() # create an (empty but valid) database
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
- ragdb.connect(db_file).close()
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 = db.connect(tmp_path / "rag.sqlite3")
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