paraview-mcp-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,671 @@
1
+ """ParaView MCP Server — External MCP server that bridges AI assistants to ParaView."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from contextlib import asynccontextmanager, suppress
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import Context, FastMCP
11
+
12
+ from paraview_mcp_server.headless import HeadlessJobManager, HeadlessPvpythonExecutor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PARAVIEW_HOST = "127.0.0.1"
17
+ PARAVIEW_PORT = 9876
18
+ HEADLESS_JOB_MANAGER = HeadlessJobManager()
19
+
20
+
21
+ class ParaViewConnection:
22
+ """Async TCP client that communicates with the ParaView bridge server."""
23
+
24
+ def __init__(self, host: str = PARAVIEW_HOST, port: int = PARAVIEW_PORT):
25
+ self.host = host
26
+ self.port = port
27
+ self._reader: asyncio.StreamReader | None = None
28
+ self._writer: asyncio.StreamWriter | None = None
29
+ self._lock = asyncio.Lock()
30
+
31
+ async def connect(self):
32
+ self._reader, self._writer = await asyncio.open_connection(self.host, self.port)
33
+ logger.info("Connected to ParaView bridge at %s:%s", self.host, self.port)
34
+
35
+ async def _drop_connection(self):
36
+ writer = self._writer
37
+ self._reader = None
38
+ self._writer = None
39
+ if writer is not None:
40
+ writer.close()
41
+ with suppress(OSError):
42
+ await writer.wait_closed()
43
+
44
+ async def disconnect(self):
45
+ await self._drop_connection()
46
+
47
+ async def send_command(self, command: str, params: dict | None = None) -> Any:
48
+ """Send a command to the ParaView bridge and return the result."""
49
+ if not self._writer:
50
+ await self.connect()
51
+
52
+ assert self._reader is not None
53
+ assert self._writer is not None
54
+ request_id = str(uuid.uuid4())
55
+
56
+ request = {
57
+ "id": request_id,
58
+ "command": command,
59
+ "params": params or {},
60
+ }
61
+
62
+ async with self._lock:
63
+ try:
64
+ self._writer.write(json.dumps(request).encode() + b"\n")
65
+ await self._writer.drain()
66
+
67
+ line = await self._reader.readline()
68
+ if not line:
69
+ raise ConnectionError("ParaView bridge connection closed")
70
+
71
+ response = json.loads(line)
72
+ if response.get("id") != request_id:
73
+ raise ConnectionError(
74
+ f"ParaView bridge sent mismatched response id {response.get('id')!r} for request {request_id!r}"
75
+ )
76
+ if not response.get("success"):
77
+ raise RuntimeError(response.get("error", "Unknown error from ParaView bridge"))
78
+ return response.get("result")
79
+ except asyncio.CancelledError:
80
+ await self._drop_connection()
81
+ raise
82
+ except (ConnectionError, OSError, json.JSONDecodeError) as e:
83
+ await self._drop_connection()
84
+ if isinstance(e, json.JSONDecodeError):
85
+ raise ConnectionError("Lost connection to ParaView bridge: invalid JSON response") from e
86
+ raise ConnectionError(f"Lost connection to ParaView bridge: {e}") from e
87
+
88
+
89
+ @asynccontextmanager
90
+ async def paraview_lifespan(server: FastMCP):
91
+ """Manage the ParaView bridge connection lifecycle."""
92
+ conn = ParaViewConnection()
93
+ try:
94
+ await conn.connect()
95
+ except OSError:
96
+ logger.warning("Could not connect to ParaView bridge on startup. Will retry on first tool call.")
97
+ yield conn
98
+ await conn.disconnect()
99
+
100
+
101
+ mcp = FastMCP(
102
+ "ParaView MCP Server",
103
+ lifespan=paraview_lifespan,
104
+ log_level="INFO",
105
+ )
106
+
107
+
108
+ def _get_conn(ctx: Context) -> ParaViewConnection:
109
+ return ctx.request_context.lifespan_context # type: ignore[no-any-return]
110
+
111
+
112
+ # ======================================================================
113
+ # Scene / session tools
114
+ # ======================================================================
115
+
116
+
117
+ @mcp.tool(
118
+ name="paraview_scene_get_info",
119
+ description="Get basic information about the current ParaView session, including source count and active view type.",
120
+ )
121
+ async def scene_get_info(ctx: Context) -> str:
122
+ result = await _get_conn(ctx).send_command("scene.get_info")
123
+ return json.dumps(result, indent=2)
124
+
125
+
126
+ @mcp.tool(
127
+ name="paraview_scene_list_sources",
128
+ description="List all sources currently loaded in the ParaView pipeline browser.",
129
+ )
130
+ async def scene_list_sources(ctx: Context) -> str:
131
+ result = await _get_conn(ctx).send_command("scene.list_sources")
132
+ return json.dumps(result, indent=2)
133
+
134
+
135
+ @mcp.tool(
136
+ name="paraview_scene_list_views",
137
+ description="List all open views/render windows in the current ParaView session.",
138
+ )
139
+ async def scene_list_views(ctx: Context) -> str:
140
+ result = await _get_conn(ctx).send_command("scene.list_views")
141
+ return json.dumps(result, indent=2)
142
+
143
+
144
+ @mcp.tool(
145
+ name="paraview_source_get_properties",
146
+ description="Get the properties and metadata of a named source in the ParaView pipeline.",
147
+ )
148
+ async def source_get_properties(ctx: Context, name: str) -> str:
149
+ result = await _get_conn(ctx).send_command("source.get_properties", {"name": name})
150
+ return json.dumps(result, indent=2)
151
+
152
+
153
+ # ======================================================================
154
+ # Data loading tools
155
+ # ======================================================================
156
+
157
+
158
+ @mcp.tool(
159
+ name="paraview_source_open_file",
160
+ description="Open a supported dataset file (VTK, VTU, VTS, ExodusII, CSV, etc.) in ParaView.",
161
+ )
162
+ async def source_open_file(ctx: Context, filepath: str) -> str:
163
+ result = await _get_conn(ctx).send_command("source.open_file", {"filepath": filepath})
164
+ return json.dumps(result, indent=2)
165
+
166
+
167
+ @mcp.tool(
168
+ name="paraview_source_delete",
169
+ description="Delete a named source from the ParaView pipeline.",
170
+ )
171
+ async def source_delete(ctx: Context, name: str) -> str:
172
+ result = await _get_conn(ctx).send_command("source.delete", {"name": name})
173
+ return json.dumps(result, indent=2)
174
+
175
+
176
+ @mcp.tool(
177
+ name="paraview_source_rename",
178
+ description="Rename a source in the ParaView pipeline.",
179
+ )
180
+ async def source_rename(ctx: Context, name: str, new_name: str) -> str:
181
+ result = await _get_conn(ctx).send_command("source.rename", {"name": name, "new_name": new_name})
182
+ return json.dumps(result, indent=2)
183
+
184
+
185
+ # ======================================================================
186
+ # Filter tools — basic
187
+ # ======================================================================
188
+
189
+
190
+ @mcp.tool(
191
+ name="paraview_filter_slice",
192
+ description=(
193
+ "Apply a Slice filter to a named source. "
194
+ "Specify origin as [x, y, z] and normal as [nx, ny, nz]. "
195
+ "Defaults: origin=[0,0,0], normal=[1,0,0]."
196
+ ),
197
+ )
198
+ async def filter_slice(
199
+ ctx: Context,
200
+ input: str,
201
+ origin: list[float] | None = None,
202
+ normal: list[float] | None = None,
203
+ ) -> str:
204
+ params: dict[str, Any] = {"input": input}
205
+ if origin is not None:
206
+ params["origin"] = origin
207
+ if normal is not None:
208
+ params["normal"] = normal
209
+ result = await _get_conn(ctx).send_command("filter.slice", params)
210
+ return json.dumps(result, indent=2)
211
+
212
+
213
+ @mcp.tool(
214
+ name="paraview_filter_clip",
215
+ description=(
216
+ "Apply a Clip filter to a named source. "
217
+ "Specify origin as [x, y, z] and normal as [nx, ny, nz]. "
218
+ "Defaults: origin=[0,0,0], normal=[1,0,0]."
219
+ ),
220
+ )
221
+ async def filter_clip(
222
+ ctx: Context,
223
+ input: str,
224
+ origin: list[float] | None = None,
225
+ normal: list[float] | None = None,
226
+ ) -> str:
227
+ params: dict[str, Any] = {"input": input}
228
+ if origin is not None:
229
+ params["origin"] = origin
230
+ if normal is not None:
231
+ params["normal"] = normal
232
+ result = await _get_conn(ctx).send_command("filter.clip", params)
233
+ return json.dumps(result, indent=2)
234
+
235
+
236
+ @mcp.tool(
237
+ name="paraview_filter_contour",
238
+ description=(
239
+ "Apply a Contour (isosurface) filter to a named source. "
240
+ "Specify the scalar array name and one or more isovalues."
241
+ ),
242
+ )
243
+ async def filter_contour(
244
+ ctx: Context,
245
+ input: str,
246
+ array: str,
247
+ values: list[float],
248
+ ) -> str:
249
+ result = await _get_conn(ctx).send_command("filter.contour", {"input": input, "array": array, "values": values})
250
+ return json.dumps(result, indent=2)
251
+
252
+
253
+ @mcp.tool(
254
+ name="paraview_filter_threshold",
255
+ description=(
256
+ "Apply a Threshold filter to a named source. Keep cells where the scalar array falls within [lower, upper]."
257
+ ),
258
+ )
259
+ async def filter_threshold(
260
+ ctx: Context,
261
+ input: str,
262
+ array: str,
263
+ lower: float,
264
+ upper: float,
265
+ ) -> str:
266
+ result = await _get_conn(ctx).send_command(
267
+ "filter.threshold",
268
+ {"input": input, "array": array, "lower": lower, "upper": upper},
269
+ )
270
+ return json.dumps(result, indent=2)
271
+
272
+
273
+ # ======================================================================
274
+ # Filter tools — advanced
275
+ # ======================================================================
276
+
277
+
278
+ @mcp.tool(
279
+ name="paraview_filter_calculator",
280
+ description=(
281
+ "Apply a Calculator filter to a named source. "
282
+ "Provide a mathematical expression (e.g. 'Pressure * 2') and an "
283
+ "optional result array name (default: 'Result')."
284
+ ),
285
+ )
286
+ async def filter_calculator(
287
+ ctx: Context,
288
+ input: str,
289
+ expression: str,
290
+ result_name: str = "Result",
291
+ attribute_type: str = "Point Data",
292
+ ) -> str:
293
+ result = await _get_conn(ctx).send_command(
294
+ "filter.calculator",
295
+ {
296
+ "input": input,
297
+ "expression": expression,
298
+ "result_name": result_name,
299
+ "attribute_type": attribute_type,
300
+ },
301
+ )
302
+ return json.dumps(result, indent=2)
303
+
304
+
305
+ @mcp.tool(
306
+ name="paraview_filter_stream_tracer",
307
+ description=("Apply a Stream Tracer filter to a named vector source. Generates streamlines from seed points."),
308
+ )
309
+ async def filter_stream_tracer(
310
+ ctx: Context,
311
+ input: str,
312
+ seed_type: str = "Point Cloud",
313
+ integration_direction: str = "BOTH",
314
+ num_points: int = 100,
315
+ max_length: float = 1.0,
316
+ ) -> str:
317
+ result = await _get_conn(ctx).send_command(
318
+ "filter.stream_tracer",
319
+ {
320
+ "input": input,
321
+ "seed_type": seed_type,
322
+ "integration_direction": integration_direction,
323
+ "num_points": num_points,
324
+ "max_length": max_length,
325
+ },
326
+ )
327
+ return json.dumps(result, indent=2)
328
+
329
+
330
+ @mcp.tool(
331
+ name="paraview_filter_glyph",
332
+ description=("Apply a Glyph filter to a named source. Glyphs visualize vector data using arrows, spheres, etc."),
333
+ )
334
+ async def filter_glyph(
335
+ ctx: Context,
336
+ input: str,
337
+ glyph_type: str = "Arrow",
338
+ scale_array: str | None = None,
339
+ scale_factor: float = 1.0,
340
+ ) -> str:
341
+ params: dict[str, Any] = {
342
+ "input": input,
343
+ "glyph_type": glyph_type,
344
+ "scale_factor": scale_factor,
345
+ }
346
+ if scale_array is not None:
347
+ params["scale_array"] = scale_array
348
+ result = await _get_conn(ctx).send_command("filter.glyph", params)
349
+ return json.dumps(result, indent=2)
350
+
351
+
352
+ # ======================================================================
353
+ # Display / coloring tools
354
+ # ======================================================================
355
+
356
+
357
+ @mcp.tool(
358
+ name="paraview_display_show",
359
+ description="Make a named source visible in the active ParaView render view.",
360
+ )
361
+ async def display_show(ctx: Context, name: str) -> str:
362
+ result = await _get_conn(ctx).send_command("display.show", {"name": name})
363
+ return json.dumps(result, indent=2)
364
+
365
+
366
+ @mcp.tool(
367
+ name="paraview_display_hide",
368
+ description="Hide a named source in the active ParaView render view.",
369
+ )
370
+ async def display_hide(ctx: Context, name: str) -> str:
371
+ result = await _get_conn(ctx).send_command("display.hide", {"name": name})
372
+ return json.dumps(result, indent=2)
373
+
374
+
375
+ @mcp.tool(
376
+ name="paraview_display_color_by",
377
+ description=(
378
+ "Color a named source by a data array. "
379
+ "Specify the array name and optionally the component index "
380
+ "(-1 = magnitude, 0/1/2 = X/Y/Z). "
381
+ "association can be 'POINTS' (default) or 'CELLS'."
382
+ ),
383
+ )
384
+ async def display_color_by(
385
+ ctx: Context,
386
+ name: str,
387
+ array: str,
388
+ component: int = -1,
389
+ association: str = "POINTS",
390
+ ) -> str:
391
+ result = await _get_conn(ctx).send_command(
392
+ "display.color_by",
393
+ {"name": name, "array": array, "component": component, "association": association},
394
+ )
395
+ return json.dumps(result, indent=2)
396
+
397
+
398
+ @mcp.tool(
399
+ name="paraview_display_set_representation",
400
+ description=(
401
+ "Set the display representation for a named source. "
402
+ "Supported types: Surface, Wireframe, Points, Surface With Edges, Volume."
403
+ ),
404
+ )
405
+ async def display_set_representation(ctx: Context, name: str, representation: str) -> str:
406
+ result = await _get_conn(ctx).send_command(
407
+ "display.set_representation",
408
+ {"name": name, "representation": representation},
409
+ )
410
+ return json.dumps(result, indent=2)
411
+
412
+
413
+ @mcp.tool(
414
+ name="paraview_display_set_opacity",
415
+ description="Set the opacity (0.0 = fully transparent, 1.0 = fully opaque) of a named source.",
416
+ )
417
+ async def display_set_opacity(ctx: Context, name: str, opacity: float) -> str:
418
+ result = await _get_conn(ctx).send_command("display.set_opacity", {"name": name, "opacity": opacity})
419
+ return json.dumps(result, indent=2)
420
+
421
+
422
+ @mcp.tool(
423
+ name="paraview_display_rescale_transfer_function",
424
+ description="Rescale the color transfer function of a named source to fit the current data range.",
425
+ )
426
+ async def display_rescale_transfer_function(ctx: Context, name: str) -> str:
427
+ result = await _get_conn(ctx).send_command("display.rescale_transfer_function", {"name": name})
428
+ return json.dumps(result, indent=2)
429
+
430
+
431
+ # ======================================================================
432
+ # View / camera tools
433
+ # ======================================================================
434
+
435
+
436
+ @mcp.tool(
437
+ name="paraview_view_reset_camera",
438
+ description="Reset the camera in the active ParaView render view to fit all visible sources.",
439
+ )
440
+ async def view_reset_camera(ctx: Context) -> str:
441
+ result = await _get_conn(ctx).send_command("view.reset_camera")
442
+ return json.dumps(result, indent=2)
443
+
444
+
445
+ @mcp.tool(
446
+ name="paraview_view_set_camera",
447
+ description=(
448
+ "Set the camera position, focal point, and view-up vector. "
449
+ "Each parameter is a [x, y, z] list. Optionally set parallel_scale for orthographic views."
450
+ ),
451
+ )
452
+ async def view_set_camera(
453
+ ctx: Context,
454
+ position: list[float] | None = None,
455
+ focal_point: list[float] | None = None,
456
+ view_up: list[float] | None = None,
457
+ parallel_scale: float | None = None,
458
+ ) -> str:
459
+ params: dict[str, Any] = {}
460
+ if position is not None:
461
+ params["position"] = position
462
+ if focal_point is not None:
463
+ params["focal_point"] = focal_point
464
+ if view_up is not None:
465
+ params["view_up"] = view_up
466
+ if parallel_scale is not None:
467
+ params["parallel_scale"] = parallel_scale
468
+ result = await _get_conn(ctx).send_command("view.set_camera", params)
469
+ return json.dumps(result, indent=2)
470
+
471
+
472
+ @mcp.tool(
473
+ name="paraview_view_set_background",
474
+ description=(
475
+ "Set the background color of the active render view. "
476
+ "Provide color as [r, g, b] with values 0-1. "
477
+ "Optionally provide color2 for a gradient background."
478
+ ),
479
+ )
480
+ async def view_set_background(
481
+ ctx: Context,
482
+ color: list[float],
483
+ color2: list[float] | None = None,
484
+ ) -> str:
485
+ params: dict[str, Any] = {"color": color}
486
+ if color2 is not None:
487
+ params["color2"] = color2
488
+ result = await _get_conn(ctx).send_command("view.set_background", params)
489
+ return json.dumps(result, indent=2)
490
+
491
+
492
+ # ======================================================================
493
+ # Export tools
494
+ # ======================================================================
495
+
496
+
497
+ @mcp.tool(
498
+ name="paraview_export_screenshot",
499
+ description=(
500
+ "Save a screenshot of the active ParaView render view to a file. "
501
+ "Supports PNG and JPEG. Default resolution is 1920×1080."
502
+ ),
503
+ )
504
+ async def export_screenshot(
505
+ ctx: Context,
506
+ filepath: str,
507
+ width: int = 1920,
508
+ height: int = 1080,
509
+ transparent: bool = False,
510
+ ) -> str:
511
+ result = await _get_conn(ctx).send_command(
512
+ "export.screenshot",
513
+ {"filepath": filepath, "width": width, "height": height, "transparent": transparent},
514
+ )
515
+ return json.dumps(result, indent=2)
516
+
517
+
518
+ @mcp.tool(
519
+ name="paraview_export_data",
520
+ description=(
521
+ "Export a named source's data to a file. "
522
+ "The output format is determined by the file extension (e.g. .vtu, .csv, .vtk)."
523
+ ),
524
+ )
525
+ async def export_data(ctx: Context, name: str, filepath: str) -> str:
526
+ result = await _get_conn(ctx).send_command("export.data", {"name": name, "filepath": filepath})
527
+ return json.dumps(result, indent=2)
528
+
529
+
530
+ @mcp.tool(
531
+ name="paraview_export_animation",
532
+ description=(
533
+ "Export an animation of the current ParaView scene. "
534
+ "The output format is determined by the file extension (e.g. .avi, .ogv, .png for frame series). "
535
+ "Default resolution is 1920×1080, frame rate 15 fps."
536
+ ),
537
+ )
538
+ async def export_animation(
539
+ ctx: Context,
540
+ filepath: str,
541
+ width: int = 1920,
542
+ height: int = 1080,
543
+ frame_rate: int = 15,
544
+ frame_start: int | None = None,
545
+ frame_end: int | None = None,
546
+ ) -> str:
547
+ params: dict[str, Any] = {
548
+ "filepath": filepath,
549
+ "width": width,
550
+ "height": height,
551
+ "frame_rate": frame_rate,
552
+ }
553
+ if frame_start is not None:
554
+ params["frame_start"] = frame_start
555
+ if frame_end is not None:
556
+ params["frame_end"] = frame_end
557
+ result = await _get_conn(ctx).send_command("export.animation", params)
558
+ return json.dumps(result, indent=2)
559
+
560
+
561
+ # ======================================================================
562
+ # Python execution tools
563
+ # ======================================================================
564
+
565
+
566
+ @mcp.tool(
567
+ name="paraview_python_exec",
568
+ description=(
569
+ "Execute a Python script in the ParaView bridge context synchronously. "
570
+ "Provide either 'code' (inline Python string) or 'script_path' (path to a .py file), not both. "
571
+ "The script has access to 'paraview.simple' as 'pvs' and an 'args' dict "
572
+ "with any supplied arguments. Set '__result__' to return a JSON-serializable value. "
573
+ "Returns result, stdout, stderr, error, and execution duration. "
574
+ "Use transport='bridge' for the live bridge session, or transport='headless' "
575
+ "to run in a separate pvpython process."
576
+ ),
577
+ )
578
+ async def python_exec(
579
+ ctx: Context,
580
+ code: str | None = None,
581
+ script_path: str | None = None,
582
+ args: dict | None = None,
583
+ timeout_seconds: int | None = None,
584
+ transport: str = "bridge",
585
+ ) -> str:
586
+ if transport == "headless":
587
+ executor = HeadlessPvpythonExecutor()
588
+ result = await executor.execute(
589
+ code=code,
590
+ script_path=script_path,
591
+ args=args,
592
+ timeout_seconds=timeout_seconds,
593
+ )
594
+ else:
595
+ params: dict[str, Any] = {}
596
+ if code is not None:
597
+ params["code"] = code
598
+ if script_path is not None:
599
+ params["script_path"] = script_path
600
+ if args is not None:
601
+ params["args"] = args
602
+ if timeout_seconds is not None:
603
+ params["timeout_seconds"] = timeout_seconds
604
+ result = await _get_conn(ctx).send_command("python.execute", params)
605
+ return json.dumps(result, indent=2)
606
+
607
+
608
+ @mcp.tool(
609
+ name="paraview_python_exec_async",
610
+ description=(
611
+ "Start a long-running Python script in ParaView asynchronously. "
612
+ "Same parameters as paraview_python_exec. Returns a job_id immediately. "
613
+ "Use paraview_job_status to poll for completion, and paraview_job_cancel to abort. "
614
+ "Uses transport='headless' to run in a separate pvpython process."
615
+ ),
616
+ )
617
+ async def python_exec_async(
618
+ ctx: Context,
619
+ code: str | None = None,
620
+ script_path: str | None = None,
621
+ args: dict | None = None,
622
+ timeout_seconds: int | None = None,
623
+ ) -> str:
624
+ executor = HeadlessPvpythonExecutor()
625
+ job_id = await HEADLESS_JOB_MANAGER.create_job(
626
+ executor,
627
+ code=code,
628
+ script_path=script_path,
629
+ args=args,
630
+ timeout_seconds=timeout_seconds,
631
+ )
632
+ return json.dumps({"job_id": job_id}, indent=2)
633
+
634
+
635
+ @mcp.tool(
636
+ name="paraview_job_status",
637
+ description=(
638
+ "Get the status of an async ParaView job. Returns job_id, status "
639
+ "(queued/running/succeeded/failed/cancelled), timestamps, result, stdout, stderr, and error. "
640
+ "Poll this after starting a job with paraview_python_exec_async."
641
+ ),
642
+ )
643
+ async def job_status(ctx: Context, job_id: str) -> str:
644
+ result = HEADLESS_JOB_MANAGER.get_status(job_id)
645
+ return json.dumps(result, indent=2)
646
+
647
+
648
+ @mcp.tool(
649
+ name="paraview_job_cancel",
650
+ description=("Cancel a running or queued async ParaView job."),
651
+ )
652
+ async def job_cancel(ctx: Context, job_id: str) -> str:
653
+ result = await HEADLESS_JOB_MANAGER.cancel(job_id)
654
+ return json.dumps(result, indent=2)
655
+
656
+
657
+ @mcp.tool(
658
+ name="paraview_job_list",
659
+ description="List known async ParaView jobs with their IDs, statuses, and creation timestamps.",
660
+ )
661
+ async def job_list(ctx: Context) -> str:
662
+ result = HEADLESS_JOB_MANAGER.list_jobs()
663
+ return json.dumps(result, indent=2)
664
+
665
+
666
+ def main():
667
+ mcp.run(transport="stdio")
668
+
669
+
670
+ if __name__ == "__main__":
671
+ main()