uxarray-mcp 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,7 @@
1
+ """UXarray MCP Server - AI tools for unstructured mesh analysis."""
2
+
3
+ from uxarray_mcp.server import mcp
4
+ from uxarray_mcp.tools import inspect_mesh
5
+
6
+ __all__ = ["mcp", "inspect_mesh"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,16 @@
1
+ """Run the UXarray MCP CLI as a module (``python -m uxarray_mcp``)."""
2
+
3
+ import sys
4
+
5
+ from uxarray_mcp.cli import main as cli_main
6
+
7
+
8
+ def main() -> None:
9
+ """Default to ``serve`` when invoked without a subcommand for back-compat."""
10
+ if len(sys.argv) == 1:
11
+ sys.argv.append("serve")
12
+ raise SystemExit(cli_main())
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
uxarray_mcp/cli.py ADDED
@@ -0,0 +1,356 @@
1
+ """Command-line interface for the UXarray MCP server.
2
+
3
+ Subcommands
4
+ -----------
5
+ - ``serve`` — run the MCP server (stdio transport)
6
+ - ``setup`` — write a minimal user config to ``~/.config/uxarray-mcp/config.yaml``
7
+ - ``doctor`` — validate local Globus auth, endpoint health, and optional remote probes
8
+ - ``endpoints`` — manage named Globus Compute endpoints in the user config
9
+ - ``install-claude`` — print or write the Claude Desktop ``mcpServers`` block
10
+
11
+ The CLI is registered via the ``uxarray-mcp`` entry point in pyproject.toml.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+ from uxarray_mcp.remote.config import (
27
+ USER_CONFIG_PATH,
28
+ discover_config_path,
29
+ discover_config_search_paths,
30
+ load_config,
31
+ )
32
+
33
+
34
+ def _read_user_config(path: Path) -> dict[str, Any]:
35
+ if not path.exists():
36
+ return {}
37
+ with open(path, "r", encoding="utf-8") as fh:
38
+ data = yaml.safe_load(fh) or {}
39
+ if not isinstance(data, dict):
40
+ return {}
41
+ return data
42
+
43
+
44
+ def _write_user_config(path: Path, data: dict[str, Any]) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ with open(path, "w", encoding="utf-8") as fh:
47
+ yaml.safe_dump(data, fh, sort_keys=False)
48
+
49
+
50
+ def _ensure_hpc_block(data: dict[str, Any]) -> dict[str, Any]:
51
+ hpc = data.setdefault("hpc", {})
52
+ if not isinstance(hpc, dict):
53
+ hpc = {}
54
+ data["hpc"] = hpc
55
+ endpoints = hpc.setdefault("endpoints", {})
56
+ if not isinstance(endpoints, dict):
57
+ hpc["endpoints"] = {}
58
+ return hpc
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # serve
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def cmd_serve(args: argparse.Namespace) -> int:
67
+ """Run the MCP server on stdio."""
68
+ from uxarray_mcp.server import mcp
69
+
70
+ mcp.run()
71
+ return 0
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # setup
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def cmd_setup(args: argparse.Namespace) -> int:
80
+ """Write a starter config to the user config path."""
81
+ target = Path(args.path).expanduser() if args.path else USER_CONFIG_PATH
82
+ if target.exists() and not args.force:
83
+ print(
84
+ f"Config already exists at {target}. Pass --force to overwrite.",
85
+ file=sys.stderr,
86
+ )
87
+ return 2
88
+
89
+ starter: dict[str, Any] = {
90
+ "hpc": {
91
+ "execution_mode": args.execution_mode,
92
+ "timeout_seconds": 300,
93
+ "default_endpoint": None,
94
+ "endpoints": {},
95
+ }
96
+ }
97
+ _write_user_config(target, starter)
98
+ print(f"Wrote starter config to {target}")
99
+ print("Add an endpoint with: uxarray-mcp endpoints add <name> <uuid>")
100
+ return 0
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # endpoints
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _user_write_target() -> Path:
109
+ """Path that mutating commands (`endpoints add/remove`) should write to.
110
+
111
+ Honors `$UXARRAY_MCP_CONFIG` so add/remove stay consistent with the
112
+ config the rest of the server reads. Falls back to ``USER_CONFIG_PATH``.
113
+ """
114
+ env_path = os.environ.get("UXARRAY_MCP_CONFIG")
115
+ if env_path:
116
+ return Path(env_path).expanduser()
117
+ return USER_CONFIG_PATH
118
+
119
+
120
+ def cmd_endpoints_list(args: argparse.Namespace) -> int:
121
+ cfg = load_config()
122
+ loaded = discover_config_path()
123
+ if not cfg.endpoints and not cfg.endpoint_id:
124
+ if loaded is None:
125
+ print("No endpoints configured. No config file was found. Searched:")
126
+ for p in discover_config_search_paths():
127
+ print(f" - {p}")
128
+ print("\nWrite a starter config with: uxarray-mcp setup")
129
+ else:
130
+ print(f"No endpoints configured in {loaded}.")
131
+ print("\nAdd one with: uxarray-mcp endpoints add <name> <uuid>")
132
+ return 0
133
+ payload: dict[str, Any] = {
134
+ "config_path": str(discover_config_path() or ""),
135
+ "default_endpoint": cfg.default_endpoint,
136
+ "execution_mode": cfg.execution_mode,
137
+ "endpoints": {
138
+ name: {
139
+ "endpoint_id": p.endpoint_id,
140
+ "path_prefixes": list(p.path_prefixes),
141
+ "timeout_seconds": p.timeout_seconds,
142
+ }
143
+ for name, p in cfg.endpoints.items()
144
+ },
145
+ }
146
+ if cfg.endpoint_id and not cfg.endpoints:
147
+ payload["legacy_endpoint_id"] = cfg.endpoint_id
148
+ print(json.dumps(payload, indent=2))
149
+ return 0
150
+
151
+
152
+ def cmd_endpoints_add(args: argparse.Namespace) -> int:
153
+ target = _user_write_target()
154
+ data = _read_user_config(target)
155
+ hpc = _ensure_hpc_block(data)
156
+ endpoints = hpc["endpoints"]
157
+ profile: dict[str, Any] = {"endpoint_id": args.uuid}
158
+ if args.path_prefix:
159
+ profile["path_prefixes"] = list(args.path_prefix)
160
+ if args.timeout_seconds is not None:
161
+ profile["timeout_seconds"] = args.timeout_seconds
162
+ endpoints[args.name] = profile
163
+ if args.set_default or not hpc.get("default_endpoint"):
164
+ hpc["default_endpoint"] = args.name
165
+ if hpc.get("execution_mode") in (None, "local"):
166
+ hpc["execution_mode"] = "auto"
167
+ _write_user_config(target, data)
168
+ print(f"Added endpoint {args.name!r} → {args.uuid} in {target}")
169
+ return 0
170
+
171
+
172
+ def cmd_endpoints_remove(args: argparse.Namespace) -> int:
173
+ target = _user_write_target()
174
+ if not target.exists():
175
+ print(f"No user config at {target}", file=sys.stderr)
176
+ return 2
177
+ data = _read_user_config(target)
178
+ hpc = _ensure_hpc_block(data)
179
+ endpoints = hpc["endpoints"]
180
+ if args.name not in endpoints:
181
+ print(f"Endpoint {args.name!r} is not configured.", file=sys.stderr)
182
+ return 2
183
+ del endpoints[args.name]
184
+ if hpc.get("default_endpoint") == args.name:
185
+ hpc["default_endpoint"] = next(iter(endpoints), None)
186
+ _write_user_config(target, data)
187
+ print(f"Removed endpoint {args.name!r} from {target}")
188
+ return 0
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # doctor
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def cmd_doctor(args: argparse.Namespace) -> int:
197
+ from uxarray_mcp.tools.execution_control import (
198
+ probe_path_access,
199
+ validate_hpc_setup,
200
+ )
201
+
202
+ sample_path = args.sample_path[0] if args.sample_path else None
203
+ report = validate_hpc_setup(
204
+ run_remote_probe=not args.skip_remote_probe,
205
+ probe_timeout_seconds=args.timeout_seconds,
206
+ sample_path=sample_path,
207
+ endpoint=args.endpoint,
208
+ )
209
+
210
+ if len(args.sample_path) > 1:
211
+ report["additional_path_probes"] = [
212
+ probe_path_access(
213
+ path,
214
+ use_remote=True,
215
+ inspect_netcdf=not args.no_netcdf,
216
+ endpoint=args.endpoint,
217
+ )
218
+ for path in args.sample_path[1:]
219
+ ]
220
+
221
+ print(json.dumps(report, indent=2, sort_keys=True))
222
+ return 0 if report.get("passed") else 1
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # install-claude
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def _build_claude_block(name: str) -> dict[str, Any]:
231
+ bin_path = shutil.which("uxarray-mcp") or "uxarray-mcp"
232
+ return {
233
+ "mcpServers": {
234
+ name: {
235
+ "command": bin_path,
236
+ "args": ["serve"],
237
+ }
238
+ }
239
+ }
240
+
241
+
242
+ def cmd_install_claude(args: argparse.Namespace) -> int:
243
+ block = _build_claude_block(args.name)
244
+ if args.print_only:
245
+ print(json.dumps(block, indent=2))
246
+ return 0
247
+
248
+ target = Path(args.config_path).expanduser() if args.config_path else None
249
+ if target is None:
250
+ print(
251
+ "Pass --config-path to merge into a Claude Desktop config, "
252
+ "or --print-only to dump the block to stdout.",
253
+ file=sys.stderr,
254
+ )
255
+ print(json.dumps(block, indent=2))
256
+ return 0
257
+
258
+ existing: dict[str, Any] = {}
259
+ if target.exists():
260
+ with open(target, "r", encoding="utf-8") as fh:
261
+ existing = json.load(fh) or {}
262
+ servers = existing.setdefault("mcpServers", {})
263
+ servers[args.name] = block["mcpServers"][args.name]
264
+ target.parent.mkdir(parents=True, exist_ok=True)
265
+ with open(target, "w", encoding="utf-8") as fh:
266
+ json.dump(existing, fh, indent=2)
267
+ print(f"Wrote Claude config to {target}")
268
+ return 0
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # parser
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ def build_parser() -> argparse.ArgumentParser:
277
+ p = argparse.ArgumentParser(
278
+ prog="uxarray-mcp",
279
+ description="UXarray MCP server CLI: serve, configure, and diagnose.",
280
+ )
281
+ sub = p.add_subparsers(dest="command", required=True)
282
+
283
+ serve = sub.add_parser("serve", help="Run the MCP server on stdio.")
284
+ serve.set_defaults(func=cmd_serve)
285
+
286
+ setup = sub.add_parser("setup", help="Write a starter user config.")
287
+ setup.add_argument("--path", default=None, help="Override target path.")
288
+ setup.add_argument(
289
+ "--execution-mode",
290
+ default="auto",
291
+ choices=["local", "auto", "hpc"],
292
+ help="Default execution mode (default: auto).",
293
+ )
294
+ setup.add_argument("--force", action="store_true", help="Overwrite existing file.")
295
+ setup.set_defaults(func=cmd_setup)
296
+
297
+ endpoints = sub.add_parser("endpoints", help="Manage Globus Compute endpoints.")
298
+ ep_sub = endpoints.add_subparsers(dest="endpoints_command", required=True)
299
+
300
+ ep_list = ep_sub.add_parser("list", help="Show configured endpoints.")
301
+ ep_list.set_defaults(func=cmd_endpoints_list)
302
+
303
+ ep_add = ep_sub.add_parser("add", help="Add or update an endpoint.")
304
+ ep_add.add_argument("name")
305
+ ep_add.add_argument("uuid")
306
+ ep_add.add_argument(
307
+ "--path-prefix",
308
+ action="append",
309
+ default=[],
310
+ help="Filesystem prefix this endpoint owns. Repeatable.",
311
+ )
312
+ ep_add.add_argument("--timeout-seconds", type=int, default=None)
313
+ ep_add.add_argument(
314
+ "--set-default", action="store_true", help="Mark this endpoint as default."
315
+ )
316
+ ep_add.set_defaults(func=cmd_endpoints_add)
317
+
318
+ ep_remove = ep_sub.add_parser("remove", help="Remove a named endpoint.")
319
+ ep_remove.add_argument("name")
320
+ ep_remove.set_defaults(func=cmd_endpoints_remove)
321
+
322
+ doctor = sub.add_parser("doctor", help="Validate HPC readiness.")
323
+ doctor.add_argument("--sample-path", action="append", default=[])
324
+ doctor.add_argument("--timeout-seconds", type=int, default=180)
325
+ doctor.add_argument("--endpoint", default=None)
326
+ doctor.add_argument("--skip-remote-probe", action="store_true")
327
+ doctor.add_argument("--no-netcdf", action="store_true")
328
+ doctor.set_defaults(func=cmd_doctor)
329
+
330
+ claude = sub.add_parser(
331
+ "install-claude", help="Print or merge a Claude Desktop mcpServers block."
332
+ )
333
+ claude.add_argument("--name", default="uxarray", help="MCP server name.")
334
+ claude.add_argument(
335
+ "--config-path",
336
+ default=None,
337
+ help="Claude config to merge into (e.g. ~/Library/Application Support/Claude/claude_desktop_config.json).",
338
+ )
339
+ claude.add_argument(
340
+ "--print-only",
341
+ action="store_true",
342
+ help="Print the JSON block without writing anywhere.",
343
+ )
344
+ claude.set_defaults(func=cmd_install_claude)
345
+
346
+ return p
347
+
348
+
349
+ def main(argv: list[str] | None = None) -> int:
350
+ parser = build_parser()
351
+ args = parser.parse_args(argv)
352
+ return int(args.func(args))
353
+
354
+
355
+ if __name__ == "__main__":
356
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ """Shared scientific computation layer for UXarray MCP Server.
2
+
3
+ Functions here contain the pure domain logic used by both local tools
4
+ (inspection.py) and remote HPC functions (compute_functions.py).
5
+ """
6
+
7
+ from .area import compute_area_stats
8
+ from .mesh import load_grid
9
+ from .variable import compute_variable_info
10
+ from .vector_calc import (
11
+ compute_azimuthal_mean,
12
+ compute_curl,
13
+ compute_divergence,
14
+ compute_gradient,
15
+ )
16
+ from .zonal import compute_zonal_mean_stats
17
+
18
+ __all__ = [
19
+ "load_grid",
20
+ "compute_area_stats",
21
+ "compute_variable_info",
22
+ "compute_zonal_mean_stats",
23
+ "compute_gradient",
24
+ "compute_curl",
25
+ "compute_divergence",
26
+ "compute_azimuthal_mean",
27
+ ]
@@ -0,0 +1,32 @@
1
+ """Shared face area computation logic."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def compute_area_stats(grid: Any) -> dict:
7
+ """Compute face area statistics for a loaded grid.
8
+
9
+ Parameters
10
+ ----------
11
+ grid : ux.Grid
12
+ Loaded UXarray grid.
13
+
14
+ Returns
15
+ -------
16
+ dict
17
+ Keys: total_area, mean_area, min_area, max_area, area_units, n_face
18
+ """
19
+ face_areas = grid.face_areas
20
+
21
+ area_units = "m^2"
22
+ if hasattr(face_areas, "attrs") and "units" in face_areas.attrs:
23
+ area_units = face_areas.attrs["units"]
24
+
25
+ return {
26
+ "total_area": float(face_areas.sum()),
27
+ "mean_area": float(face_areas.mean()),
28
+ "min_area": float(face_areas.min()),
29
+ "max_area": float(face_areas.max()),
30
+ "area_units": area_units,
31
+ "n_face": int(grid.n_face),
32
+ }
@@ -0,0 +1,26 @@
1
+ """Shared grid loading with HEALPix support."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def load_grid(file_path: str) -> Any:
7
+ """Load a UXarray Grid from a file path or HEALPix spec.
8
+
9
+ Parameters
10
+ ----------
11
+ file_path : str
12
+ Path to mesh file, or "healpix:<zoom>" for virtual HEALPix meshes.
13
+
14
+ Returns
15
+ -------
16
+ ux.Grid
17
+ Loaded grid object.
18
+ """
19
+ import uxarray as ux
20
+
21
+ if file_path.lower().startswith("healpix"):
22
+ parts = file_path.split(":")
23
+ zoom = int(parts[1]) if len(parts) > 1 else 1
24
+ return ux.Grid.from_healpix(zoom=zoom)
25
+
26
+ return ux.open_grid(file_path)