uxarray-mcp 0.2.0__tar.gz → 0.2.2__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.
Files changed (38) hide show
  1. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/PKG-INFO +3 -2
  2. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/pyproject.toml +3 -2
  3. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/pyproject.toml.orig +4 -2
  4. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/__init__.py +1 -1
  5. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/vector_calc.py +9 -1
  6. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/provenance.py +5 -1
  7. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/remote/agent.py +29 -1
  8. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/remote/compute_functions.py +107 -1
  9. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/remote/config.py +18 -1
  10. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/catalog.py +14 -34
  11. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/execution_control.py +3 -2
  12. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/frontdoor.py +5 -2
  13. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/orchestration.py +23 -10
  14. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/remote_tools.py +28 -3
  15. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/stateful.py +44 -7
  16. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/LICENSE +0 -0
  17. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/README.md +0 -0
  18. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/__main__.py +0 -0
  19. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/app.py +0 -0
  20. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/cli.py +0 -0
  21. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/__init__.py +0 -0
  22. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/area.py +0 -0
  23. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/mesh.py +0 -0
  24. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/plotting.py +0 -0
  25. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/variable.py +0 -0
  26. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/domain/zonal.py +0 -0
  27. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/py.typed +0 -0
  28. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/registry.py +0 -0
  29. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/remote/__init__.py +0 -0
  30. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/remote/health.py +0 -0
  31. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/state.py +0 -0
  32. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/__init__.py +0 -0
  33. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/advanced.py +0 -0
  34. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/capabilities.py +0 -0
  35. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/inspection.py +0 -0
  36. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/plotting.py +0 -0
  37. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/scientific_agent.py +0 -0
  38. {uxarray_mcp-0.2.0 → uxarray_mcp-0.2.2}/src/uxarray_mcp/tools/vector_calc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: uxarray-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: MCP server for analyzing unstructured meshes with UXarray
5
5
  Keywords: uxarray,mcp,unstructured grids,scientific computing,globus compute
6
6
  Author: Rajeev Jain, Dayan Abdulla
@@ -215,10 +215,11 @@ Classifier: Programming Language :: Python :: 3
215
215
  Classifier: Programming Language :: Python :: 3.12
216
216
  Classifier: License :: OSI Approved :: Apache Software License
217
217
  Requires-Dist: toolregistry-server[mcp]>=0.4.0
218
+ Requires-Dist: mcp>=1.20,<2
218
219
  Requires-Dist: holoviews>=1.19.0
219
220
  Requires-Dist: matplotlib>=3.9.0
220
221
  Requires-Dist: pyyaml>=6.0
221
- Requires-Dist: uxarray>=2026.6.0
222
+ Requires-Dist: uxarray>=2026.7.0
222
223
  Requires-Dist: sphinx>=7.0 ; extra == 'docs'
223
224
  Requires-Dist: sphinx-book-theme>=1.1.0 ; extra == 'docs'
224
225
  Requires-Dist: myst-parser>=3.0 ; extra == 'docs'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uxarray-mcp"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "MCP server for analyzing unstructured meshes with UXarray"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -22,10 +22,11 @@ classifiers = [
22
22
  requires-python = ">=3.12,<3.13"
23
23
  dependencies = [
24
24
  "toolregistry-server[mcp]>=0.4.0",
25
+ "mcp>=1.20,<2",
25
26
  "holoviews>=1.19.0",
26
27
  "matplotlib>=3.9.0",
27
28
  "pyyaml>=6.0",
28
- "uxarray>=2026.6.0",
29
+ "uxarray>=2026.7.0",
29
30
  ]
30
31
 
31
32
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uxarray-mcp"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "MCP server for analyzing unstructured meshes with UXarray"
5
5
  readme = "README.md"
6
6
  keywords = ["uxarray", "mcp", "unstructured grids", "scientific computing", "globus compute"]
@@ -26,10 +26,12 @@ license = { file = "LICENSE" }
26
26
  requires-python = ">=3.12,<3.13"
27
27
  dependencies = [
28
28
  "toolregistry-server[mcp]>=0.4.0",
29
+ # toolregistry-server 0.4.x imports McpError, renamed to MCPError in mcp 2.
30
+ "mcp>=1.20,<2",
29
31
  "holoviews>=1.19.0",
30
32
  "matplotlib>=3.9.0",
31
33
  "pyyaml>=6.0",
32
- "uxarray>=2026.6.0",
34
+ "uxarray>=2026.7.0",
33
35
  ]
34
36
 
35
37
  [project.optional-dependencies]
@@ -3,4 +3,4 @@
3
3
  from uxarray_mcp.tools import inspect_mesh
4
4
 
5
5
  __all__ = ["inspect_mesh"]
6
- __version__ = "0.2.0"
6
+ __version__ = "0.2.2"
@@ -354,7 +354,15 @@ def compute_curl(
354
354
  from uxarray_mcp.provenance import attach_scientific_status
355
355
 
356
356
  return attach_scientific_status(
357
- result, warnings=component_warnings, warning_codes=warning_codes
357
+ result,
358
+ warnings=component_warnings,
359
+ warning_codes=warning_codes,
360
+ extra={
361
+ "physical_scaling_requested": bool(scale_by_radius),
362
+ "physical_scaling_applied": bool(
363
+ scale_by_radius and not deduped_uxarray_warnings
364
+ ),
365
+ },
358
366
  )
359
367
 
360
368
 
@@ -84,13 +84,17 @@ def attach_scientific_status(
84
84
  *,
85
85
  warnings: list[str] | None = None,
86
86
  warning_codes: list[str] | None = None,
87
+ extra: dict[str, Any] | None = None,
87
88
  ) -> dict[str, Any]:
88
89
  """Attach a machine-actionable scientific interpretation status."""
89
90
  messages = warnings or []
90
- result["scientific_status"] = {
91
+ status = {
91
92
  "status": "warning" if messages else "complete",
92
93
  "physically_interpretable": not messages,
93
94
  "warning_codes": warning_codes or [],
94
95
  "warnings": messages,
95
96
  }
97
+ if extra:
98
+ status.update(extra)
99
+ result["scientific_status"] = status
96
100
  return result
@@ -166,6 +166,19 @@ class UXarrayComputeAgent(_AcademyAgent):
166
166
  else:
167
167
  return self._run_local_inspect_variable(grid_path, data_path, variable_name)
168
168
 
169
+ @action
170
+ async def validate_dataset_remote(
171
+ self, grid_path: str, data_path: str, use_remote: bool = False
172
+ ) -> Dict[str, Any]:
173
+ """Validate a dataset in the environment that can read its paths."""
174
+ from uxarray_mcp.remote.compute_functions import remote_validate_dataset
175
+
176
+ if use_remote and self.config.endpoint_id:
177
+ return await self._run_on_hpc(remote_validate_dataset, grid_path, data_path)
178
+ from uxarray_mcp.tools.inspection import validate_dataset
179
+
180
+ return validate_dataset(grid_path, data_path)
181
+
169
182
  @action
170
183
  async def calculate_zonal_mean_remote(
171
184
  self,
@@ -453,9 +466,14 @@ class UXarrayComputeAgent(_AcademyAgent):
453
466
  # — not the local submitter — and warn on local/remote version drift.
454
467
  drift_warnings: list[str] = []
455
468
  worker_uxarray = None
469
+ worker_runtime: dict[str, Any] = {}
456
470
  if isinstance(result, dict):
457
471
  worker_uxarray = result.pop("_worker_uxarray_version", None)
458
- result.pop("_worker_python_version", None)
472
+ worker_runtime = result.pop("_worker_runtime", {}) or {}
473
+ worker_python = result.pop("_worker_python_version", None)
474
+ if worker_python and "python_version" not in worker_runtime:
475
+ worker_runtime["python_version"] = worker_python
476
+ worker_uxarray = worker_runtime.get("uxarray_version") or worker_uxarray
459
477
  local_uxarray = _get_uxarray_version()
460
478
  if (
461
479
  worker_uxarray
@@ -484,6 +502,16 @@ class UXarrayComputeAgent(_AcademyAgent):
484
502
  # Record the worker's uxarray version explicitly alongside the local one.
485
503
  if worker_uxarray:
486
504
  annotated["_provenance"]["remote_uxarray_version"] = worker_uxarray
505
+ for key in (
506
+ "hostname",
507
+ "python_version",
508
+ "xarray_version",
509
+ "numpy_version",
510
+ "slurm_job_id",
511
+ "pbs_job_id",
512
+ ):
513
+ if worker_runtime.get(key):
514
+ annotated["_provenance"][f"remote_{key}"] = worker_runtime[key]
487
515
  return annotated
488
516
 
489
517
  def _run_local_inspect_mesh(self, file_path: str) -> Dict[str, Any]:
@@ -162,6 +162,80 @@ def remote_inspect_mesh(file_path: str) -> Dict[str, Any]:
162
162
  "n_edge": int(grid.n_edge),
163
163
  "source": file_path,
164
164
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
165
+ "_worker_python_version": __import__("platform").python_version(),
166
+ }
167
+
168
+
169
+ def remote_validate_dataset(grid_path: str, data_path: str) -> Dict[str, Any]:
170
+ """Validate numeric variables on the worker that can read the dataset."""
171
+ import os
172
+ import platform
173
+
174
+ import numpy as np
175
+ import uxarray as ux
176
+
177
+ if grid_path.startswith("healpix:"):
178
+ grid = ux.Grid.from_healpix(int(grid_path.split(":")[1]))
179
+ xr_ds = __import__("xarray").open_dataset(data_path)
180
+ uxds = ux.UxDataset(xr_ds, uxgrid=grid)
181
+ elif os.path.splitext(grid_path.lower())[1] in [".shp", ".geojson"]:
182
+ grid = ux.Grid.from_file(grid_path, backend="geopandas")
183
+ xr_ds = __import__("xarray").open_dataset(data_path)
184
+ uxds = ux.UxDataset(xr_ds, uxgrid=grid)
185
+ else:
186
+ uxds = ux.open_dataset(grid_path, data_path)
187
+
188
+ fill_candidates = [1e20, 9.96920996838687e36, -999.0, -9999.0]
189
+ results = []
190
+ all_warnings: list[str] = []
191
+ for name in uxds.data_vars:
192
+ values = uxds[name].values
193
+ if not np.issubdtype(values.dtype, np.number):
194
+ continue
195
+ is_float = np.issubdtype(values.dtype, np.floating)
196
+ n_nan = int(np.sum(np.isnan(values))) if is_float else 0
197
+ n_inf = int(np.sum(np.isinf(values))) if is_float else 0
198
+ n_fill = 0
199
+ detected_fill = None
200
+ if is_float:
201
+ for candidate in fill_candidates:
202
+ count = int(np.sum(np.isclose(values, candidate, rtol=1e-3)))
203
+ if count:
204
+ n_fill = count
205
+ detected_fill = candidate
206
+ break
207
+ warnings = []
208
+ if n_nan:
209
+ warnings.append(f"{name}: contains {n_nan} NaN values")
210
+ if n_inf:
211
+ warnings.append(f"{name}: contains {n_inf} Inf values")
212
+ if n_fill:
213
+ warnings.append(f"{name}: contains {n_fill} fill values")
214
+ all_warnings.extend(warnings)
215
+ results.append(
216
+ {
217
+ "name": name,
218
+ "passed": not warnings,
219
+ "n_nan": n_nan,
220
+ "n_inf": n_inf,
221
+ "n_fill_values": n_fill,
222
+ "detected_fill_value": detected_fill,
223
+ "shape": list(values.shape),
224
+ "dtype": str(values.dtype),
225
+ "warnings": warnings,
226
+ }
227
+ )
228
+
229
+ passed = all(item["passed"] for item in results)
230
+ return {
231
+ "passed": passed,
232
+ "is_valid": passed,
233
+ "n_variables_checked": len(results),
234
+ "n_variables_failed": sum(not item["passed"] for item in results),
235
+ "variables": results,
236
+ "issues": all_warnings,
237
+ "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
238
+ "_worker_python_version": platform.python_version(),
165
239
  }
166
240
 
167
241
 
@@ -212,6 +286,7 @@ def remote_calculate_area(file_path: str) -> Dict[str, Any]:
212
286
  "area_units": units,
213
287
  "n_face": int(grid.n_face),
214
288
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
289
+ "_worker_python_version": __import__("platform").python_version(),
215
290
  }
216
291
 
217
292
 
@@ -264,6 +339,17 @@ def remote_inspect_variable(
264
339
 
265
340
  variables = []
266
341
 
342
+ def _jsonable(value: Any) -> Any:
343
+ if isinstance(value, np.generic):
344
+ return value.item()
345
+ if isinstance(value, np.ndarray):
346
+ return value.tolist()
347
+ if isinstance(value, dict):
348
+ return {str(key): _jsonable(item) for key, item in value.items()}
349
+ if isinstance(value, (list, tuple)):
350
+ return [_jsonable(item) for item in value]
351
+ return value
352
+
267
353
  for name in var_names:
268
354
  if name not in uxds:
269
355
  continue
@@ -298,7 +384,7 @@ def remote_inspect_variable(
298
384
  "shape": list(var.shape),
299
385
  "dtype": str(var.dtype),
300
386
  "location": location,
301
- "attrs": dict(var.attrs),
387
+ "attrs": _jsonable(dict(var.attrs)),
302
388
  "statistics": stats,
303
389
  }
304
390
  )
@@ -311,6 +397,7 @@ def remote_inspect_variable(
311
397
  "n_edge": int(uxds.uxgrid.n_edge),
312
398
  },
313
399
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
400
+ "_worker_python_version": __import__("platform").python_version(),
314
401
  }
315
402
 
316
403
 
@@ -707,6 +794,7 @@ def remote_calculate_zonal_mean(
707
794
  "n_edge": int(uxds.uxgrid.n_edge),
708
795
  },
709
796
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
797
+ "_worker_python_version": __import__("platform").python_version(),
710
798
  }
711
799
 
712
800
 
@@ -1155,6 +1243,7 @@ def remote_calculate_gradient(
1155
1243
  "warnings": uxarray_warnings,
1156
1244
  },
1157
1245
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1246
+ "_worker_python_version": __import__("platform").python_version(),
1158
1247
  }
1159
1248
 
1160
1249
 
@@ -1260,6 +1349,12 @@ def remote_calculate_curl(
1260
1349
  applied_scale = bool(scale_by_radius)
1261
1350
  else:
1262
1351
  result = u.curl(v)
1352
+ if scale_by_radius:
1353
+ component_warnings.append(
1354
+ "Worker UXarray does not support scale_by_radius; returned "
1355
+ "the unit-sphere curl."
1356
+ )
1357
+ warning_codes.append("SCALE_BY_RADIUS_UNSUPPORTED")
1263
1358
  _seen: set = set()
1264
1359
  for _w in _caught:
1265
1360
  _msg = str(_w.message)
@@ -1267,6 +1362,7 @@ def remote_calculate_curl(
1267
1362
  _seen.add(_msg)
1268
1363
  component_warnings.append(_msg)
1269
1364
  warning_codes.append("SPHERE_RADIUS_UNAVAILABLE")
1365
+ applied_scale = False
1270
1366
  vals = result.values
1271
1367
  finite = vals[np.isfinite(vals)]
1272
1368
  stats: Dict[str, Any] = (
@@ -1290,10 +1386,13 @@ def remote_calculate_curl(
1290
1386
  "scientific_status": {
1291
1387
  "status": "warning" if component_warnings else "complete",
1292
1388
  "physically_interpretable": not component_warnings,
1389
+ "physical_scaling_requested": bool(scale_by_radius),
1390
+ "physical_scaling_applied": bool(applied_scale),
1293
1391
  "warning_codes": warning_codes,
1294
1392
  "warnings": component_warnings,
1295
1393
  },
1296
1394
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1395
+ "_worker_python_version": __import__("platform").python_version(),
1297
1396
  }
1298
1397
 
1299
1398
 
@@ -1420,6 +1519,7 @@ def remote_calculate_divergence(
1420
1519
  "warnings": component_warnings,
1421
1520
  },
1422
1521
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1522
+ "_worker_python_version": __import__("platform").python_version(),
1423
1523
  }
1424
1524
 
1425
1525
 
@@ -1474,6 +1574,7 @@ def remote_calculate_azimuthal_mean(
1474
1574
  "azimuthal_mean_values": values,
1475
1575
  "n_face": int(uxds.uxgrid.n_face),
1476
1576
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1577
+ "_worker_python_version": __import__("platform").python_version(),
1477
1578
  }
1478
1579
 
1479
1580
 
@@ -1516,6 +1617,7 @@ def remote_grid_facts(
1516
1617
  "n_node": int(grid.n_node) if hasattr(grid, "n_node") else 0,
1517
1618
  "n_edge": int(grid.n_edge) if hasattr(grid, "n_edge") else 0,
1518
1619
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1620
+ "_worker_python_version": __import__("platform").python_version(),
1519
1621
  }
1520
1622
 
1521
1623
  if data_path is not None:
@@ -1634,6 +1736,7 @@ def remote_remap_variable(
1634
1736
  "result_shape": list(remapped.shape),
1635
1737
  "stats": stats,
1636
1738
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1739
+ "_worker_python_version": __import__("platform").python_version(),
1637
1740
  }
1638
1741
 
1639
1742
 
@@ -1715,6 +1818,7 @@ def remote_regrid_dataset(
1715
1818
  },
1716
1819
  "per_variable_stats": per_variable,
1717
1820
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1821
+ "_worker_python_version": __import__("platform").python_version(),
1718
1822
  }
1719
1823
 
1720
1824
 
@@ -1781,6 +1885,7 @@ def remote_remap_to_rectilinear(
1781
1885
  "target_lon": lon,
1782
1886
  "target_lat": lat,
1783
1887
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1888
+ "_worker_python_version": __import__("platform").python_version(),
1784
1889
  }
1785
1890
 
1786
1891
 
@@ -1860,4 +1965,5 @@ def remote_calculate_zonal_anomaly(
1860
1965
  "n_edge": int(uxds.uxgrid.n_edge),
1861
1966
  },
1862
1967
  "_worker_uxarray_version": getattr(ux, "__version__", "unknown"),
1968
+ "_worker_python_version": __import__("platform").python_version(),
1863
1969
  }
@@ -162,7 +162,7 @@ class HPCConfig:
162
162
  def resolve_endpoint(
163
163
  self, endpoint: str | None = None, path: str | None = None
164
164
  ) -> EndpointProfile | None:
165
- """Resolve an explicit endpoint name, default endpoint, or raw UUID."""
165
+ """Resolve an explicit endpoint, matching path prefix, or default."""
166
166
  if endpoint:
167
167
  if endpoint in self.endpoints:
168
168
  return self.endpoints[endpoint]
@@ -185,6 +185,23 @@ class HPCConfig:
185
185
  "Pass a configured endpoint name or a Globus Compute endpoint UUID."
186
186
  )
187
187
 
188
+ if path:
189
+ matches: list[tuple[int, EndpointProfile, str]] = []
190
+ for profile in self.endpoints.values():
191
+ for prefix in profile.path_prefixes:
192
+ if path.startswith(prefix):
193
+ matches.append((len(prefix), profile, prefix))
194
+ if matches:
195
+ longest = max(length for length, _, _ in matches)
196
+ best = [item for item in matches if item[0] == longest]
197
+ names = {profile.name for _, profile, _ in best}
198
+ if len(names) > 1:
199
+ raise ValueError(
200
+ f"Path {path!r} matches equally specific endpoint prefixes: "
201
+ + ", ".join(sorted(names))
202
+ )
203
+ return best[0][1]
204
+
188
205
  if self.default_endpoint and self.default_endpoint in self.endpoints:
189
206
  return self.endpoints[self.default_endpoint]
190
207
 
@@ -2,13 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import warnings
6
5
  from pathlib import Path
7
6
  from typing import Any, Dict, List
8
7
 
9
8
  from uxarray_mcp.provenance import attach_provenance
10
9
  from uxarray_mcp.remote.agent import get_agent
11
- from uxarray_mcp.remote.config import load_config
12
10
  from uxarray_mcp.tools.remote_tools import _endpoint_is_ready
13
11
 
14
12
  # File extensions recognised as potential mesh/data files
@@ -36,6 +34,7 @@ def list_datasets(
36
34
  recursive: bool = False,
37
35
  max_files: int = 200,
38
36
  use_remote: bool = False,
37
+ endpoint: str | None = None,
39
38
  ) -> Dict[str, Any]:
40
39
  """Scan a directory for mesh and data files and return a structured catalog.
41
40
 
@@ -80,7 +79,7 @@ def list_datasets(
80
79
  list_datasets("/lus/grand/projects/climate", recursive=True, max_files=500)
81
80
  """
82
81
  if use_remote:
83
- return _list_datasets_remote(directory, recursive, max_files)
82
+ return _list_datasets_remote(directory, recursive, max_files, endpoint)
84
83
 
85
84
  root = Path(directory)
86
85
  if not root.exists():
@@ -187,6 +186,7 @@ def list_datasets(
187
186
  "recursive": recursive,
188
187
  "max_files": max_files,
189
188
  "use_remote": False,
189
+ "endpoint": endpoint,
190
190
  },
191
191
  )
192
192
 
@@ -307,49 +307,28 @@ def _remote_catalog_fn(
307
307
 
308
308
 
309
309
  def _list_datasets_remote(
310
- directory: str, recursive: bool, max_files: int
310
+ directory: str, recursive: bool, max_files: int, endpoint: str | None = None
311
311
  ) -> Dict[str, Any]:
312
- """Run list_datasets on the configured HPC endpoint."""
313
- config = load_config()
314
- if not config.has_endpoint:
312
+ """Run list_datasets on the selected HPC endpoint."""
313
+ agent = get_agent(endpoint=endpoint, path=directory)
314
+ config = agent.config
315
+ if not config.endpoint_id:
315
316
  raise RuntimeError(
316
317
  "No HPC endpoint configured. Set endpoint_id in config.yaml "
317
318
  "or the GLOBUS_COMPUTE_ENDPOINT_ID environment variable."
318
319
  )
319
320
 
320
- agent = get_agent()
321
321
  ready, reason = _endpoint_is_ready(agent)
322
322
  if not ready:
323
323
  raise RuntimeError(
324
324
  f"HPC endpoint not ready ({reason}). Remote catalog scan not submitted."
325
325
  )
326
326
 
327
- try:
328
- from globus_compute_sdk import Executor
329
- from globus_compute_sdk.serialize import AllCodeStrategies, ComputeSerializer
330
- except ImportError as exc:
331
- raise RuntimeError(
332
- "HPC dependencies not installed. Run: uv sync --extra hpc"
333
- ) from exc
334
-
335
- with warnings.catch_warnings():
336
- warnings.filterwarnings(
337
- "ignore",
338
- message=r"(?s).*Environment differences detected between local SDK and endpoint.*",
339
- category=UserWarning,
340
- )
341
- executor = Executor(
342
- endpoint_id=config.endpoint_id,
343
- serializer=ComputeSerializer(strategy_code=AllCodeStrategies()),
344
- )
345
- with warnings.catch_warnings():
346
- warnings.filterwarnings(
347
- "ignore",
348
- message=r"(?s).*Environment differences detected between local SDK and endpoint.*",
349
- category=UserWarning,
350
- )
351
- future = executor.submit(_remote_catalog_fn, directory, recursive, max_files)
352
- raw = future.result(timeout=config.timeout_seconds)
327
+ from uxarray_mcp.tools.remote_tools import _run_sync
328
+
329
+ raw = _run_sync(
330
+ lambda: agent._run_on_hpc(_remote_catalog_fn, directory, recursive, max_files)
331
+ )
353
332
 
354
333
  if "error" in raw:
355
334
  raise FileNotFoundError(raw["error"])
@@ -364,6 +343,7 @@ def _list_datasets_remote(
364
343
  "recursive": recursive,
365
344
  "max_files": max_files,
366
345
  "use_remote": True,
346
+ "endpoint": endpoint,
367
347
  },
368
348
  venue=f"hpc:{endpoint_label}",
369
349
  )
@@ -145,11 +145,12 @@ def _run_sync(awaitable_factory) -> Dict[str, Any]:
145
145
  """Run an async call from sync code in CLI and server contexts."""
146
146
  try:
147
147
  asyncio.get_running_loop()
148
- with concurrent.futures.ThreadPoolExecutor() as pool:
149
- return pool.submit(asyncio.run, awaitable_factory()).result()
150
148
  except RuntimeError:
151
149
  return asyncio.run(awaitable_factory())
152
150
 
151
+ with concurrent.futures.ThreadPoolExecutor() as pool:
152
+ return pool.submit(lambda: asyncio.run(awaitable_factory())).result()
153
+
153
154
 
154
155
  def probe_path_access(
155
156
  file_path: str,
@@ -113,12 +113,13 @@ def run_analysis(
113
113
  subset_polygon,
114
114
  write_result,
115
115
  )
116
- from uxarray_mcp.tools.inspection import calculate_zonal_anomaly, validate_dataset
116
+ from uxarray_mcp.tools.inspection import calculate_zonal_anomaly
117
117
  from uxarray_mcp.tools.remote_tools import (
118
118
  calculate_area,
119
119
  calculate_zonal_mean,
120
120
  inspect_mesh,
121
121
  inspect_variable,
122
+ validate_dataset,
122
123
  )
123
124
  from uxarray_mcp.tools.vector_calc import (
124
125
  calculate_azimuthal_mean,
@@ -146,10 +147,12 @@ def run_analysis(
146
147
  session_id=session_id,
147
148
  )
148
149
  if op == "validate_dataset":
149
- _reject_unsupported_remote(use_remote, op)
150
150
  return validate_dataset(
151
151
  _require(grid_path, "grid_path", op),
152
152
  _require(data_path, "data_path", op),
153
+ use_remote=use_remote,
154
+ endpoint=endpoint,
155
+ session_id=session_id,
153
156
  )
154
157
  if op == "calculate_area":
155
158
  return calculate_area(
@@ -46,6 +46,7 @@ def _png_meta(items: list[Any]) -> dict[str, Any]:
46
46
  "image_size_bytes": image_size_bytes,
47
47
  "grid_info": meta.get("grid_info"),
48
48
  "variable_name": meta.get("variable_name"),
49
+ "_provenance": meta.get("_provenance", {}),
49
50
  }
50
51
 
51
52
 
@@ -126,6 +127,7 @@ def analyze_dataset(
126
127
  inspect_variable,
127
128
  plot_mesh,
128
129
  plot_variable,
130
+ validate_dataset,
129
131
  )
130
132
 
131
133
  resolved_grid, resolved_data = _resolve_plot_paths(
@@ -155,14 +157,15 @@ def analyze_dataset(
155
157
  selected_variable: Optional[str] = variable_name
156
158
 
157
159
  if resolved_data is not None:
158
- # validate_dataset is local-only by design (data is read directly);
159
- # if the dispatcher were ever added, prefer that. For now use the
160
- # local variant via inspection.validate_dataset.
161
- from .inspection import validate_dataset as _validate_dataset
162
-
163
160
  validation = _safe_call(
164
161
  "validate_dataset",
165
- lambda: _validate_dataset(resolved_grid, resolved_data),
162
+ lambda: validate_dataset(
163
+ resolved_grid,
164
+ resolved_data,
165
+ use_remote=use_remote,
166
+ endpoint=endpoint,
167
+ session_id=session_id,
168
+ ),
166
169
  warnings,
167
170
  )
168
171
  if validation is not None:
@@ -204,7 +207,7 @@ def analyze_dataset(
204
207
 
205
208
  # ── Stage 5: zonal mean (needs data + face-centered variable) ───────────
206
209
  zonal_mean: Optional[dict[str, Any]] = None
207
- validation_passed = validation is None or validation.get("passed") is not False
210
+ validation_passed = validation is not None and validation.get("passed") is True
208
211
  if (
209
212
  resolved_data is not None
210
213
  and selected_variable is not None
@@ -226,7 +229,7 @@ def analyze_dataset(
226
229
  stages_run.append("calculate_zonal_mean")
227
230
  elif resolved_data is not None and selected_variable is not None:
228
231
  warnings.append(
229
- "calculate_zonal_mean: skipped because dataset validation failed."
232
+ "calculate_zonal_mean: skipped because dataset validation failed or was unavailable."
230
233
  )
231
234
 
232
235
  # ── Stage 6 + 7: plots (optional) ────────────────────────────────────────
@@ -269,7 +272,9 @@ def analyze_dataset(
269
272
  variable_plot = _png_meta(var_plot_items)
270
273
  stages_run.append("plot_variable")
271
274
  elif resolved_data is not None and selected_variable is not None:
272
- warnings.append("plot_variable: skipped because dataset validation failed.")
275
+ warnings.append(
276
+ "plot_variable: skipped because dataset validation failed or was unavailable."
277
+ )
273
278
 
274
279
  # ── Recommended next steps ──────────────────────────────────────────────
275
280
  next_steps: list[str] = []
@@ -322,7 +327,15 @@ def analyze_dataset(
322
327
  # (used by tests that import `inspect_mesh` directly from this module).
323
328
  _ = inspect_mesh # keep import alive
324
329
 
325
- stage_results = [mesh, variables, area, zonal_mean]
330
+ stage_results = [
331
+ mesh,
332
+ validation,
333
+ variables,
334
+ area,
335
+ zonal_mean,
336
+ mesh_plot,
337
+ variable_plot,
338
+ ]
326
339
  venues = {
327
340
  stage.get("_provenance", {}).get("execution_venue")
328
341
  for stage in stage_results
@@ -48,13 +48,15 @@ def _run_sync(async_call: Callable[[], Any]) -> Dict[str, Any]:
48
48
  """
49
49
  try:
50
50
  asyncio.get_running_loop()
51
- # Inside async context (e.g. MCP server) — run in a new thread
52
- with concurrent.futures.ThreadPoolExecutor() as pool:
53
- return pool.submit(asyncio.run, async_call()).result()
54
51
  except RuntimeError:
55
52
  # No event loop running (tests, CLI) — run directly
56
53
  return asyncio.run(async_call())
57
54
 
55
+ # Inside async context (e.g. MCP server) — run in a new thread. Keep the
56
+ # operation outside the loop-detection try so its RuntimeError is preserved.
57
+ with concurrent.futures.ThreadPoolExecutor() as pool:
58
+ return pool.submit(lambda: asyncio.run(async_call())).result()
59
+
58
60
 
59
61
  def _path_is_locally_reachable(path_hint: str | None) -> bool:
60
62
  """True when local fallback can plausibly handle ``path_hint``.
@@ -220,6 +222,29 @@ def inspect_mesh(
220
222
  )
221
223
 
222
224
 
225
+ def validate_dataset(
226
+ grid_path: str,
227
+ data_path: str,
228
+ use_remote: bool = False,
229
+ endpoint: str | None = None,
230
+ session_id: str | None = None,
231
+ ) -> Dict[str, Any]:
232
+ """Validate a dataset locally or on the selected endpoint."""
233
+ from uxarray_mcp.tools.inspection import validate_dataset as local_validate
234
+
235
+ return _run_with_optional_hpc(
236
+ tool_name="validate_dataset",
237
+ use_remote=use_remote,
238
+ endpoint=endpoint,
239
+ path_hint=grid_path,
240
+ session_id=session_id,
241
+ local_call=lambda: local_validate(grid_path, data_path),
242
+ remote_call=lambda agent: _run_sync(
243
+ lambda: agent.validate_dataset_remote(grid_path, data_path, True)
244
+ ),
245
+ )
246
+
247
+
223
248
  def calculate_area(
224
249
  file_path: str,
225
250
  use_remote: bool = False,
@@ -159,12 +159,12 @@ def _execute_workflow(workflow_id: str, *, resume: bool) -> dict[str, Any]:
159
159
  probe_path_access,
160
160
  validate_hpc_setup,
161
161
  )
162
- from uxarray_mcp.tools.inspection import validate_dataset
163
162
  from uxarray_mcp.tools.remote_tools import (
164
163
  calculate_area,
165
164
  calculate_zonal_mean,
166
165
  inspect_mesh,
167
166
  inspect_variable,
167
+ validate_dataset,
168
168
  )
169
169
 
170
170
  workflow = get_workflow(workflow_id)
@@ -182,6 +182,8 @@ def _execute_workflow(workflow_id: str, *, resume: bool) -> dict[str, Any]:
182
182
  data_path = workflow["inputs"].get("data_path")
183
183
  variable_name = workflow["inputs"].get("variable_name")
184
184
  sample_path = workflow["inputs"].get("sample_path")
185
+ use_remote = bool(workflow["inputs"].get("use_remote", False))
186
+ endpoint = workflow["inputs"].get("endpoint")
185
187
  session_id = workflow.get("session_id")
186
188
 
187
189
  steps: list[tuple[str, Any]] = [
@@ -190,17 +192,31 @@ def _execute_workflow(workflow_id: str, *, resume: bool) -> dict[str, Any]:
190
192
  lambda: validate_hpc_setup(
191
193
  run_remote_probe=True,
192
194
  sample_path=sample_path,
195
+ endpoint=endpoint,
193
196
  ),
194
197
  ),
195
198
  (
196
199
  "probe_path_access",
197
- lambda: probe_path_access(sample_path or file_path, use_remote=False),
200
+ lambda: probe_path_access(
201
+ sample_path or file_path,
202
+ use_remote=use_remote,
203
+ endpoint=endpoint,
204
+ ),
205
+ ),
206
+ (
207
+ "inspect_mesh",
208
+ lambda: inspect_mesh(file_path, use_remote=use_remote, endpoint=endpoint),
198
209
  ),
199
- ("inspect_mesh", lambda: inspect_mesh(file_path)),
200
210
  (
201
211
  "inspect_variable",
202
212
  lambda: (
203
- inspect_variable(file_path, data_path, variable_name)
213
+ inspect_variable(
214
+ file_path,
215
+ data_path,
216
+ variable_name,
217
+ use_remote=use_remote,
218
+ endpoint=endpoint,
219
+ )
204
220
  if data_path is not None
205
221
  else {"skipped": True}
206
222
  ),
@@ -208,12 +224,20 @@ def _execute_workflow(workflow_id: str, *, resume: bool) -> dict[str, Any]:
208
224
  (
209
225
  "validate_dataset",
210
226
  lambda: (
211
- validate_dataset(file_path, data_path)
227
+ validate_dataset(
228
+ file_path,
229
+ data_path,
230
+ use_remote=use_remote,
231
+ endpoint=endpoint,
232
+ )
212
233
  if data_path is not None
213
234
  else {"skipped": True}
214
235
  ),
215
236
  ),
216
- ("calculate_area", lambda: calculate_area(file_path)),
237
+ (
238
+ "calculate_area",
239
+ lambda: calculate_area(file_path, use_remote=use_remote, endpoint=endpoint),
240
+ ),
217
241
  ]
218
242
 
219
243
  # `calculate_zonal_mean` depends on inspected variables and validation.
@@ -286,7 +310,13 @@ def _execute_workflow(workflow_id: str, *, resume: bool) -> dict[str, Any]:
286
310
  )
287
311
  tracker.stage(zonal_step_name, f"Running {zonal_step_name}")
288
312
  update_workflow_step(workflow_id, zonal_step_name, status="running")
289
- zonal_result = calculate_zonal_mean(file_path, data_path, selected_variable)
313
+ zonal_result = calculate_zonal_mean(
314
+ file_path,
315
+ data_path,
316
+ selected_variable,
317
+ use_remote=use_remote,
318
+ endpoint=endpoint,
319
+ )
290
320
  last_results[zonal_step_name] = zonal_result
291
321
  update_workflow_step(
292
322
  workflow_id,
@@ -341,6 +371,8 @@ def run_workflow(
341
371
  session_id: str | None = None,
342
372
  dataset_handle: str | None = None,
343
373
  sample_path: str | None = None,
374
+ use_remote: bool = False,
375
+ endpoint: str | None = None,
344
376
  ) -> dict[str, Any]:
345
377
  """Run the canonical scientific workflow with persisted state and progress."""
346
378
  resolved_grid, resolved_data = _resolve_workflow_inputs(
@@ -356,6 +388,8 @@ def run_workflow(
356
388
  "data_path": resolved_data,
357
389
  "variable_name": variable_name,
358
390
  "sample_path": sample_path,
391
+ "use_remote": use_remote,
392
+ "endpoint": endpoint,
359
393
  },
360
394
  session_id=session_id,
361
395
  steps=[
@@ -379,7 +413,10 @@ def run_workflow(
379
413
  "session_id": session_id,
380
414
  "dataset_handle": dataset_handle,
381
415
  "sample_path": sample_path,
416
+ "use_remote": use_remote,
417
+ "endpoint": endpoint,
382
418
  },
419
+ venue=(f"hpc:{endpoint}" if use_remote and endpoint else "local"),
383
420
  )
384
421
 
385
422
 
File without changes
File without changes