diff --git a/CHANGELOG.md b/CHANGELOG.md index b81cc41..f1e760f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,11 @@ under their entry. - Apache 2.0 LICENSE. ### Changed +- Reduced the public MCP surface to front-door tools (`run_analysis`, + `plot_dataset`, `diagnose_endpoint`, `manage_session`, `get_status`, + `get_result`, workflows, and discovery) while keeping low-level + implementation functions available as Python APIs. Slash commands now point + at workflows/front doors instead of individual implementation tools. - Collapsed the `*_hpc` tool surface. The unified tools (`inspect_mesh`, `inspect_variable`, `calculate_area`, plotting tools, etc.) accept `use_remote: bool` and `endpoint: str | None`; there are diff --git a/README.md b/README.md index 07ffa21..d55a95c 100644 --- a/README.md +++ b/README.md @@ -98,49 +98,32 @@ endpoints are not shadowed by an empty user config. 7. [docs/chrysalis.md](docs/chrysalis.md) if you are on Argonne Chrysalis 6. [docs/workflows.md](docs/workflows.md) for sequential remote workflows -## Main Tools - -Analysis: - -- `inspect_mesh` — topology, format detection, face/node/edge counts -- `inspect_variable` — variable metadata, location, and statistics -- `calculate_area` — face area statistics -- `calculate_zonal_mean` — latitude-band averaging (conservative or standard) -- `validate_dataset` — NaN, Inf, and fill value checks -- `run_scientific_agent` — autonomous Analyze → Plan → Execute → Verify pipeline -- `subset_bbox` / `subset_polygon` / `extract_cross_section` — spatial queries and regional reductions -- `calculate_gradient`, `calculate_curl`, `calculate_divergence`, `calculate_azimuthal_mean` — vector calculus and radial summaries -- `compare_fields`, `calculate_bias`, `calculate_rmse`, `calculate_pattern_correlation` — same-grid comparison metrics -- `remap_variable` / `regrid_dataset` — UXarray-backed remapping to a target grid -- `calculate_temporal_mean`, `calculate_anomaly`, `calculate_ensemble_mean`, `calculate_ensemble_spread` — temporal and ensemble summaries -- `export_to_netcdf`, `export_to_csv`, `write_result` — persist derived results to downstream formats - -Stateful workflows: - -- `create_session`, `register_dataset`, `get_session_state`, `reset_session_state` -- `run_workflow`, `resume_workflow`, `get_workflow_status` -- `get_result_handle`, `get_operation_status`, `list_operations` - -Visualization (returns inline PNG): - -- `plot_mesh` — mesh wireframe -- `plot_mesh_geo` — geographic mesh plot with boundary-aware rendering -- `plot_variable` — face-centered variable as filled polygon map; supports `cmap`, `vmin`, `vmax`, `title` -- `plot_zonal_mean` — latitude vs. value line chart; supports `line_color`, `title` - -HPC diagnostics: - -- `get_execution_mode` / `set_execution_mode` -- `endpoint_status` -- `validate_hpc_setup` -- `probe_path_access` - -All inspection, computation, and plotting tools accept ``use_remote: bool`` -and ``endpoint: str | None``. When ``use_remote=True`` the dispatcher submits -to the configured (or named) Globus Compute endpoint and falls back to local -execution if the endpoint is missing or unhealthy. There are no separate -``*_hpc`` tool names on the MCP surface — the same tool runs locally or -remotely based on the flag. +## MCP Front-Door Tools + +The MCP surface is intentionally small. Low-level UXarray functions are still +available as Python APIs inside `uxarray_mcp.tools`, but MCP clients see +intent-shaped tools: + +- `get_capabilities` — discover topology, variables, applicable operations, + and next steps. +- `analyze_dataset` — deterministic first-look pipeline: inspect, validate, + area, zonal mean, and plots where possible. +- `run_analysis` — one-operation dispatcher for inspection, validation, + area/zonal statistics, subsetting, vector calculus, comparison, remapping, + temporal/ensemble summaries, and export. +- `plot_dataset` — mesh, geographic mesh, variable, or zonal-mean plots. +- `diagnose_endpoint` and `probe_path_access` — endpoint status, setup + validation, and exact path readability checks. +- `run_workflow`, `resume_workflow`, `get_status`, `get_result`, and + `manage_session` — persisted sessions, workflows, operation status, and + result handles. + +`analyze_dataset`, `run_analysis`, `plot_dataset`, and `probe_path_access` +accept ``use_remote: bool`` and ``endpoint: str | None`` where remote execution +applies. When ``use_remote=True`` the dispatcher submits to the configured (or +named) Globus Compute endpoint and falls back to local execution if the endpoint +is missing or unhealthy. There are no separate ``*_hpc`` tool names on the MCP +surface. Full parameter and return details live in [docs/tools.md](docs/tools.md). @@ -165,7 +148,7 @@ Remote execution has three separate layers: Most confusing failures happen because only one or two of those layers are set up. Start with [docs/globus-compute.md](docs/globus-compute.md) and use -`validate_hpc_setup()` before real remote jobs. +`diagnose_endpoint(action="validate")` before real remote jobs. ## Configuration diff --git a/docs/architecture.html b/docs/architecture.html index ffe5627..f048dab 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -204,7 +204,7 @@

UXarray MCP Server — Architecture Diagram

Receive tool call - run_scientific_agent(path) + analyze_dataset(path) Remote execution selected per call @@ -253,17 +253,17 @@

UXarray MCP Server — Architecture Diagram

- + - inspect_mesh( ) + run_analysis(inspect_mesh) domain/mesh.py → ux.open_grid() MPAS · UGRID · SCRIP · HEALPix → n_face · n_node · n_edge · format - + - inspect_mesh(use_remote) + run_analysis(remote) ✓ _endpoint_is_ready( ) pre-flight remote/compute_functions.py @@ -432,7 +432,7 @@

UXarray MCP Server — Architecture Diagram

-
13
MCP Tools (max)
+
11
MCP Front Doors
5
Architecture Layers
142
Tests Passing
4
Domain Modules
@@ -449,34 +449,32 @@

UXarray MCP Server — Architecture Diagram

Orange boxes = dispatched to a named HPC endpoint via Globus Compute — the file never leaves the cluster. Teal dashed boxes = steps that only activate under certain conditions (data_path provided, remote mode requested). Red dashed inside HPC boxes = automatic local fallback when the endpoint is unreachable. - Unified registration: tools are registered once; remote execution is selected with use_remote=True and optional endpoint names. + Small public surface: MCP clients call front-door tools such as run_analysis, plot_dataset, and diagnose_endpoint; implementation functions stay behind those dispatchers.
-
Representative MCP Tools  — unified local / remote surface
+
MCP Front-Door Tools  — small public surface, rich internal capabilities
- - - - - - - - - - - - - + + + + + + + + + + +
#Tool NameSource FileRuns OnDomain ModuleWhat It Does
1inspect_meshinspection.pyLOCALmesh.pyLoad grid → n_face, n_node, n_edge, format, file_size_mb. Supports MPAS · UGRID · SCRIP · HEALPix and more.
2inspect_variableinspection.pyLOCALvariable.pyVariable metadata: location (faces/nodes/edges), shape, dtype, min/max/mean, units, attrs.
3calculate_areainspection.pyLOCALarea.pyFace area stats: total, mean, min, max, units, n_face via grid.face_areas.
4calculate_zonal_meaninspection.pyLOCALzonal.pyLatitude-band averaging of any face-centered variable. Optional conservative area-weighting.
5validate_datasetinspection.pyLOCALvariable.pyCheck NaN coverage, fill value consistency, dimension alignment. Result gates zonal_mean in the agent.
6get_capabilitiescapabilities.pyLOCALmesh.py · variable.pyInspect topology + variables; return applicable tools and UXarray API methods with recommendations.
7get_execution_modeexecution_control.pyLOCALReturns current execution mode (local / hpc / auto) and whether an HPC endpoint is configured.
8set_execution_modeexecution_control.pyLOCALSwitch execution mode from the Claude UI without editing config.yaml directly.
9run_scientific_agentscientific_agent.pyAUTOAll 4 modulesAutonomous 4-stage pipeline: Analyze → Plan → Execute → Verify. Validation-gated. Returns full reasoning trace + provenance + artifacts.
10inspect_meshremote_tools.pyHPC*mesh.py on HPCMesh inspection with use_remote=True. Pre-flight health check. Auto-fallback to local.
11calculate_arearemote_tools.pyHPC*area.py on HPCFace area calculation with use_remote=True. Pre-flight health check. Auto-fallback to local.
12inspect_variableremote_tools.pyHPC*variable.py on HPCVariable inspection with use_remote=True. Pre-flight health check. Auto-fallback to local.
13calculate_zonal_meanremote_tools.pyHPC*zonal.py on HPCZonal mean with use_remote=True. Pre-flight health check. Auto-fallback to local.
1get_capabilitiescapabilities.pyLOCALmesh.py · variable.pyDiscover topology, variables, applicable operations, and next steps.
2analyze_datasetorchestration.pyAUTOCore domainsRun first-look inspection, validation, area, zonal mean, and plots.
3run_analysisfrontdoor.pyAUTOAll domainsDispatch one named operation: inspect, subset, compare, remap, vector, temporal, ensemble, or export.
4plot_datasetfrontdoor.pyAUTOplotting.pyRender mesh, geographic mesh, variable, or zonal mean plots.
5diagnose_endpointfrontdoor.pyAUTOEndpoint status, setup validation, and path-probe workflows.
6probe_path_accessexecution_control.pyAUTODirect path readability probe for cluster bring-up.
7run_workflowstateful.pyAUTOCore domainsRun the canonical persisted workflow.
8resume_workflowstateful.pyAUTOCore domainsResume a persisted workflow.
9get_statusfrontdoor.pyLOCALRead workflow or operation status.
10get_resultfrontdoor.pyLOCALInspect persisted result handles and artifacts.
11manage_sessionfrontdoor.pyLOCALCreate sessions, register datasets, inspect/reset state, and list operations.
- * Remote execution is selected per call with use_remote=True. Endpoint readiness controls remote dispatch and fallback. + * Remote execution is selected per call with use_remote=True on front doors that support remote work. Endpoint readiness controls remote dispatch and fallback.
diff --git a/docs/architecture.md b/docs/architecture.md index c79cdb8..3483965 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,15 +41,17 @@ The UXarray MCP Server is organized into three layers: ### Local execution -1. The MCP client calls a tool such as `inspect_mesh`. -2. The tool implementation in `tools/` validates inputs and chooses the local path. +1. The MCP client calls a front-door tool such as + `run_analysis(operation="inspect_mesh", grid_path="...")`. +2. The front door routes to the implementation in `tools/` and chooses the local path. 3. The shared computation in `domain/` runs through UXarray. 4. The result gets a `_provenance` block and is returned to the client. ### Remote execution -1. The MCP client calls a tool such as `inspect_mesh(..., use_remote=True)`. -2. The HPC wrapper checks endpoint readiness and configuration. +1. The MCP client calls a front-door tool such as + `run_analysis(operation="inspect_mesh", grid_path="...", use_remote=True)`. +2. The implementation wrapper checks endpoint readiness and configuration. 3. The remote agent submits a self-contained function from `remote/compute_functions.py` through Globus Compute. 4. The endpoint receives that function and runs it in the remote worker environment. 5. The result comes back to the local machine, where provenance is attached before returning it to the client. @@ -75,9 +77,12 @@ transport itself. Globus Compute is still the actual remote execution system. ## Tools Layer (`tools/`) -MCP tool functions that are registered with the FastMCP server and exposed to AI agents. Each tool handles input validation, calls into the domain layer, attaches provenance, and returns structured results. +The FastMCP server exposes a small front-door tool surface. Those tools route +to lower-level implementation functions in `tools/`, which handle input +validation, domain calls, remote dispatch, provenance, and structured results. -- **inspection.py** — Core local tools: mesh inspection, variable inspection, area calculation, zonal mean, dataset validation +- **frontdoor.py** — Public MCP front doors: operation dispatch, plotting dispatch, endpoint diagnostics, session management, status, and result lookup +- **inspection.py** — Core local implementations: mesh inspection, variable inspection, area calculation, zonal mean, dataset validation - **remote_tools.py** — HPC-enabled wrappers with pre-flight health checks and automatic fallback to local - **scientific_agent.py** — Autonomous four-stage agent (Analyze > Plan > Execute > Verify) - **capabilities.py** — Tool discovery and filtering based on grid topology and data @@ -103,10 +108,15 @@ HPC infrastructure for offloading computations to remote clusters via Globus Com ## Key Design Decisions -**Unified tool surface** — Tools are registered under one canonical name and -accept `use_remote` / `endpoint` parameters when remote execution is available. -The dispatcher falls back locally or reports endpoint readiness issues instead -of exposing separate HPC-only tool names. +**Small public tool surface** — MCP clients see intent-shaped front doors such +as `run_analysis`, `plot_dataset`, and `diagnose_endpoint`, not every +implementation function. Lower-level functions remain available to Python +callers and internal workflows. + +**Unified local/remote dispatch** — Front doors accept `use_remote` / +`endpoint` parameters when remote execution is available. The dispatcher falls +back locally or reports endpoint readiness issues instead of exposing separate +HPC-only tool names. **Domain/tool separation** — Computation logic lives in `domain/` so the same code can run locally or be serialized and sent to a remote cluster without importing MCP dependencies. diff --git a/docs/chrysalis.md b/docs/chrysalis.md index be98c55..f94458e 100644 --- a/docs/chrysalis.md +++ b/docs/chrysalis.md @@ -77,7 +77,7 @@ uv run --extra hpc python scripts/yac_smoke_test.py \ --endpoint chrysalis --timeout-seconds 300 ``` -Or manually: +Or manually through the Python API: ```python from uxarray_mcp.tools.execution_control import endpoint_status, validate_hpc_setup diff --git a/docs/getting-started.md b/docs/getting-started.md index fa1a4fd..60815a4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -195,7 +195,7 @@ Fully quit (`Cmd+Q` on macOS) and reopen. Closing the window is not enough. In Claude, ask: ``` -Do you have access to an inspect_mesh tool? +Do you have access to the UXarray MCP front-door tools? ``` Claude should confirm and describe the available tools. @@ -205,7 +205,7 @@ Claude should confirm and describe the available tools. **Inspect a HEALPix mesh:** ``` -Use inspect_mesh with healpix:4 +Use run_analysis with operation="inspect_mesh" and grid_path="healpix:4" ``` **Run the full scientific agent:** @@ -225,20 +225,19 @@ Run the workflow for temperature using the registered dataset **Check and change execution mode:** ``` -What is the current execution mode? -Switch to HPC execution mode +Diagnose my configured endpoint status ``` **Validate the full HPC path before a real job:** ``` -Run validate_hpc_setup +Run diagnose_endpoint with action="validate" ``` **Validate one exact remote file before debugging UXarray parsing:** ``` -Run validate_hpc_setup with probe_timeout_seconds=180 and sample_path="/path/to/file.nc" +Run diagnose_endpoint with action="validate" and file_path="/path/to/file.nc" Run probe_path_access on /path/to/file.nc with use_remote=True ``` @@ -259,13 +258,14 @@ For the full HPC playbook and reusable scripts, see: 3. Check the Developer Console: View > Developer > Developer Tools > Console **Remote calls fall back locally** -: Run `endpoint_status(probe=True)` or `validate_hpc_setup` and make sure the +: Run `diagnose_endpoint(action="status")` or + `diagnose_endpoint(action="validate")` and make sure the endpoint manager and worker probe both pass. The tool list itself is unified; there are no separate HPC-only tool names. **Endpoint is registered but remote tasks still fail** -: `get_execution_mode` only confirms the endpoint manager is reachable. - Run `validate_hpc_setup` to catch deeper issues such as missing local Globus +: Endpoint status only confirms the endpoint manager is reachable. + Run `diagnose_endpoint(action="validate")` to catch deeper issues such as missing local Globus auth, missing `globus_compute_sdk`, PBS submission failures like `qsub: command not found`, or child-endpoint startup problems. @@ -275,7 +275,8 @@ For the full HPC playbook and reusable scripts, see: **Brand-new cluster bring-up is getting stuck in PBS/SLURM** : Start with a single-host endpoint template first. Prove that - `validate_hpc_setup(..., sample_path=...)` and `probe_path_access(..., use_remote=True)` + `diagnose_endpoint(action="validate", file_path=...)` and + `probe_path_access(..., use_remote=True)` work on one real file, then switch the endpoint back to PBS/SLURM. **I want reusable CLI helpers, not just MCP prompts** diff --git a/docs/hpc.md b/docs/hpc.md index 9a99d71..3c3d35e 100644 --- a/docs/hpc.md +++ b/docs/hpc.md @@ -223,8 +223,8 @@ uv run python scripts/hpc_doctor.py \ Or from the MCP client: ```text -Run validate_hpc_setup -Run validate_hpc_setup with probe_timeout_seconds=180 and sample_path="/path/to/file.nc" +Run diagnose_endpoint with action="validate" +Run diagnose_endpoint with action="validate", probe_timeout_seconds=180, and file_path="/path/to/file.nc" Run probe_path_access on /path/to/file.nc with use_remote=True ``` @@ -241,8 +241,8 @@ These checks validate, in order: Once the remote runtime and file path are proven, then use: ```text -Use inspect_mesh on /path/to/grid.nc with use_remote=True -Use inspect_variable on /path/to/grid.nc and /path/to/data.nc with use_remote=True +Use run_analysis with operation="inspect_mesh", grid_path="/path/to/grid.nc", and use_remote=True +Use run_analysis with operation="inspect_variable", grid_path="/path/to/grid.nc", data_path="/path/to/data.nc", and use_remote=True ``` ## Switching Back to PBS or SLURM @@ -266,15 +266,15 @@ to the environment: scripts/improv_endpoint.sh pbs-debug improv-uxarray ``` -Then restart the endpoint and rerun `validate_hpc_setup`. +Then restart the endpoint and rerun `diagnose_endpoint(action="validate")`. ## Common Misses -### `get_execution_mode()` says `registered`, but real jobs fail +### Endpoint status says `registered`, but real jobs fail That means the endpoint manager is visible to Globus, but the child endpoint or -worker may still be broken. Use `validate_hpc_setup()` instead of trusting -manager health alone. +worker may still be broken. Use `diagnose_endpoint(action="validate")` instead +of trusting manager health alone. ### The file path looks correct on the login node, but the worker says it does not exist diff --git a/docs/tools.md b/docs/tools.md index 224e646..c637000 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,607 +1,146 @@ # Tools Reference -All tools available through the MCP server. +The MCP server exposes a small set of front-door tools. Low-level UXarray +operations still exist as Python functions in `uxarray_mcp.tools`, but MCP +clients should use the intent-shaped tools below. -Most analysis and control tools return structured dictionaries with a -`_provenance` block for traceability. The plotting tools are different: they -return two MCP content blocks, an inline PNG image plus a JSON text block that -contains the provenance metadata. +Most tools return structured dictionaries with a `_provenance` block. Plotting +returns MCP content blocks: an inline PNG plus JSON metadata. -## Core Tools - -### `inspect_mesh` - -Analyze mesh topology — faces, nodes, edges, format detection. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `file_path` | `str` | Path to mesh file, or `healpix:` to generate a HEALPix mesh | - -**Returns:** `format`, `n_face`, `n_node`, `n_edge`, `n_max_face_nodes`, `file_size_mb`, `_provenance` - -**Supported formats:** MPAS, UGRID, SCRIP, ESMF, Exodus, FESOM, ICON, HEALPix - ---- - -### `inspect_variable` - -Inspect data variables on a mesh — location, shape, statistics, attributes. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` (optional) | Specific variable to inspect, or all if omitted | - -**Returns:** `variables` list with `name`, `dims`, `shape`, `dtype`, `location`, `statistics`, `attrs`; plus `_provenance` - ---- - -### `calculate_area` - -Calculate face areas for a mesh. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `file_path` | `str` | Path to mesh file, or `healpix:` | - -**Returns:** `total_area`, `mean_area`, `min_area`, `max_area`, `area_units`, `n_face`, `_provenance` - ---- - -### `calculate_zonal_mean` - -Latitude-band average of a face-centered variable. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` | Name of the face-centered variable to average | -| `lat_spec` | `tuple`, `float`, `list`, or `None` | Latitude specification (see below) | -| `conservative` | `bool` | Area-weighted averaging over bands (default: `False`) | - -**`lat_spec` options:** - -- `None` — default bands from -90 to 90 in 10-degree steps -- `(start, end, step)` — custom range -- `float` — single latitude -- `list` — explicit band edges - -**Returns:** `variable_name`, `latitudes`, `zonal_mean_values`, `conservative`, `grid_info`, `_provenance` - ---- - -### `validate_dataset` - -Check dataset integrity — NaN coverage, Inf values, and common fill value detection. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` | Path to the data file | - -**Returns:** `passed`, `n_variables_checked`, `n_variables_failed`, per-variable details, `_provenance` - ---- - -### `list_datasets` - -Scan a local directory for NetCDF, HDF5, and GRIB files and group results by -subdirectory with heuristic grid-vs-data classification. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `directory` | `str` | Local path to scan | -| `recursive` | `bool` | Recurse into subdirectories (default: `False`) | -| `max_files` | `int` | Cap the result set to avoid huge trees (default: `200`) | - -**Returns:** `directory`, `total_files`, `truncated`, grouped `files`, `recommendations`, `_provenance` - -Use this when you have a directory full of climate files and need a quick map -of likely grid files, data files, and next-step tool calls. - ---- +## Front-Door Tools ### `get_capabilities` -Discover which tools and UXarray features apply to a specific grid and dataset. Filters results based on grid topology and variable locations. +Discover mesh topology, variables, applicable `run_analysis` operations, native +UXarray methods, and recommended next steps. -**Parameters:** +Parameters: | Name | Type | Description | |------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` (optional) | Path to the data file | +| `grid_path` | `str` | Path to grid/mesh file, or `healpix:` | +| `data_path` | `str` optional | Path to a data file | -**Returns:** Available MCP tools, applicable native UXarray methods, and recommendations. +### `analyze_dataset` ---- +Run the deterministic first-look pipeline in one call: inspect mesh, validate +data when provided, inspect variables, calculate face areas, calculate a zonal +mean when possible, and produce mesh/variable plots when requested. -### `get_execution_mode` +Parameters include `grid_path`, `data_path`, `variable_name`, `session_id`, +`dataset_handle`, `use_remote`, `endpoint`, and `include_plots`. -Returns the current execution mode and endpoint manager status. +### `run_analysis` -**Returns:** `mode`, `endpoint_name`, `endpoint_configured`, `endpoint_status`, `description`, `status_note` +Run one named operation without exposing dozens of separate MCP tools. -`endpoint_status` is one of: +Supported operations: -| Value | Meaning | +| Operation | Purpose | |---|---| -| `"registered"` | Manager is running; Slurm/PBS will allocate workers on demand | -| `"offline"` | Manager is not running — SSH in and start it | -| `"unreachable"` | Cannot contact Globus Compute | -| `"no_endpoint"` | No endpoint configured | - -`"registered"` does **not** mean workers are actively running. Use -`endpoint_status(probe=True)` or `validate_hpc_setup()` to confirm a real -worker responds. - ---- - -### `set_execution_mode` - -Switch execution mode without editing config files. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `mode` | `str` | One of `"local"`, `"hpc"`, or `"auto"` | - -**Returns:** `mode`, `previous_mode`, `endpoint_name`, `endpoint_configured`, `message` - ---- - -### `validate_hpc_setup` - -Runs a deeper HPC readiness diagnostic than `get_execution_mode`. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `run_remote_probe` | `bool` | Submit a tiny remote task after status checks (default: `True`) | -| `probe_timeout_seconds` | `int` | Timeout for the remote probe (default: `30`) | -| `sample_path` | `str` (optional) | Exact remote path to probe after the runtime probe succeeds | - -**Returns:** `passed`, `mode`, `endpoint_name`, `endpoint_configured`, `endpoint_status`, `checks`, `remote_probe`, `sample_path_probe`, `_provenance` - -Use this when an endpoint is `"registered"` but real remote calls hang or -fall back locally. It surfaces problems like missing local Globus auth, -missing `globus_compute_sdk`, scheduler bootstrap failures (`qsub: command not found`), -and worker environment issues. - ---- - -### `probe_path_access` - -Proves whether the exact target path is readable. This is the right first check -on a new cluster before trying UXarray-specific tools. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `file_path` | `str` | Local or remote path to probe | -| `use_remote` | `bool` | Set to `True` to execute the probe remotely | -| `inspect_netcdf` | `bool` | Attempt a generic NetCDF open and summarize dims/variables | - -**Returns:** Path readability details, file metadata, optional NetCDF summary, `_provenance` - ---- - -## Session And Workflow Tools - -### `create_session` - -Create a persisted scientific session for datasets, workflows, results, and -operation tracking. - -**Parameters:** `name` (optional) - -**Returns:** `session_id`, `name`, `created_at`, `_provenance` - ---- - -### `register_dataset` - -Register a grid/data pair in a session so later calls can use a -`dataset_handle` instead of repeating file paths. - -**Parameters:** `session_id`, `grid_path`, `data_path` (optional), `name` (optional) - -**Returns:** `dataset_handle`, `dataset`, `dataset_count`, `_provenance` - ---- - -### `run_workflow` - -Run the persisted canonical workflow: -`validate_hpc_setup` → `probe_path_access` → `inspect_*` → `validate_dataset` -→ `calculate_area` → `calculate_zonal_mean` when applicable. - -**Parameters:** `file_path` or `dataset_handle`, optional `data_path`, `variable_name`, `session_id`, `sample_path` - -**Returns:** Workflow record with `workflow_id`, `status`, per-step states, `events`, `result_handle`, `_provenance` - ---- - -### `resume_workflow`, `get_workflow_status`, `get_result_handle`, `get_operation_status`, `list_operations`, `get_session_state`, `reset_session_state` - -Inspect or manage persisted workflow/session state. - -These tools let clients: - -- resume a previously failed or partial workflow -- inspect per-step status and progress events -- inspect persisted result handles and artifact paths -- list tracked long-running operations -- reset session-scoped results, workflows, and operation history - ---- - -## Advanced Analysis Tools - -### `subset_bbox`, `subset_polygon`, `extract_cross_section` - -Spatial query tools for cropping or slicing a mesh or face-centered variable. - -**Returns:** selection metadata, subset summary, `result_handle`, `_provenance` - ---- - -### `compare_fields`, `calculate_bias`, `calculate_rmse`, `calculate_pattern_correlation` - -Same-grid comparison metrics for aligned fields. - -`compare_fields` also persists a difference field artifact and returns a -`difference_field_handle` that can be exported later. - ---- - -### `remap_variable`, `regrid_dataset` - -Transfer one variable or multiple face-centered variables onto a target grid -using UXarray-supported remapping methods. - -**Key parameters:** source grid/data, `target_grid_path`, variable selection, -`method`, `remap_to` - ---- - -### `calculate_temporal_mean`, `calculate_anomaly` - -Time-aware summaries for variables with a `time` dimension. - -`calculate_temporal_mean` supports a whole-time mean or grouped means via -`groupby`. `calculate_anomaly` currently uses the temporal mean as the v1 -baseline. - ---- - -### `calculate_ensemble_mean`, `calculate_ensemble_spread` - -Ensemble summaries across explicitly provided `data_paths`. All ensemble -members must share the same dims and shape in v1. - ---- - -### `export_to_netcdf`, `export_to_csv`, `write_result` - -Persist a prior `result_handle` or registered session dataset to NetCDF or CSV. - -Use these tools when a derived result needs to be shared outside the MCP -response channel or fed into a downstream workflow. - ---- - -## Helper Scripts - -For repeatable bring-up and debugging, see: - -- `scripts/hpc_doctor.py` — first-pass CLI doctor for local auth, endpoint status, remote no-op execution, and optional real-path probing -- `scripts/agentic_hpc_loop.py` -- `scripts/improv_endpoint.sh` - -## Plotting Tools - -These tools return: - -- an inline PNG image block that MCP clients can render directly -- a JSON text block with `_provenance`, image metadata, and selected inputs - -### `plot_mesh` - -Render a mesh wireframe from a grid file or a `healpix:` mesh spec. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the mesh file or `healpix:` | -| `width` | `int` | Image width in pixels (default: `800`) | -| `height` | `int` | Image height in pixels (default: `400`) | - -**JSON metadata returns:** `image_size_bytes`, `grid_info`, `_provenance` - ---- - -### `plot_variable` - -Render a face-centered variable as a filled polygon map. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` (optional) | Variable to plot, or first face-centered variable if omitted | -| `width` | `int` | Image width in pixels (default: `800`) | -| `height` | `int` | Image height in pixels (default: `400`) | -| `cmap` | `str` | Matplotlib colormap name (default: `"viridis"`) | -| `vmin` | `float` (optional) | Lower bound for the color scale | -| `vmax` | `float` (optional) | Upper bound for the color scale | -| `title` | `str` (optional) | Custom plot title | - -**JSON metadata returns:** `image_size_bytes`, `variable_name`, `grid_info`, `_provenance` - ---- - -### `plot_zonal_mean` - -Compute and render a zonal-mean profile as latitude versus value. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the grid/mesh file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` | Face-centered variable to average and plot | -| `width` | `int` | Image width in pixels (default: `800`) | -| `height` | `int` | Image height in pixels (default: `400`) | -| `lat_spec` | `tuple`, `float`, `list`, or `None` | Latitude specification for zonal bands | -| `conservative` | `bool` | Area-weighted conservative averaging (default: `False`) | -| `line_color` | `str` | Matplotlib color string (default: `"#1f77b4"`) | -| `title` | `str` (optional) | Custom plot title | - -**JSON metadata returns:** `image_size_bytes`, `variable_name`, `latitudes`, `zonal_mean_values`, `_provenance` - -## Vector Calculus Tools - -These tools compute differential operators on face-centered fields using -UXarray's Green-Gauss finite-volume method. All require face-centered variables -and return statistics over the unstructured mesh. - ---- - -### `calculate_gradient` - -Compute the spatial gradient (∂/∂x, ∂/∂y) of a scalar field. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the mesh grid file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` | Face-centered scalar variable | - -**Returns:** `variable_name`, `components` (list of output variable names), `component_stats` (min/max/mean per component), `n_face`, `interpretation`, `_provenance` - ---- - -### `calculate_curl` - -Compute the curl of a 2-D vector field — equivalent to **relative vorticity** for wind: - -> ζ = ∂v/∂x − ∂u/∂y - -This is the primary diagnostic for identifying cyclones, anticyclones, and jet-stream -structure on unstructured meshes. Positive vorticity = cyclonic (counterclockwise in -Northern Hemisphere). Negative = anticyclonic. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the mesh grid file | -| `data_path` | `str` | Path to the data file | -| `u_variable` | `str` | Zonal (east–west) component, e.g. `"uReconstructZonal"` | -| `v_variable` | `str` | Meridional (north–south) component, e.g. `"uReconstructMeridional"` | - -**Returns:** `u_variable`, `v_variable`, `interpretation`, `n_face`, `stats` (min/max/mean/std), `_provenance` - ---- - -### `calculate_divergence` - -Compute the horizontal divergence of a 2-D vector field: - -> ∇·V = ∂u/∂x + ∂v/∂y - -Negative divergence (convergence) drives rising motion and convection. Positive -divergence indicates sinking motion. Used together with `calculate_curl` to -characterise the full kinematic structure of atmospheric flow. - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the mesh grid file | -| `data_path` | `str` | Path to the data file | -| `u_variable` | `str` | Zonal (east–west) component | -| `v_variable` | `str` | Meridional (north–south) component | - -**Returns:** `u_variable`, `v_variable`, `interpretation`, `n_face`, `stats` (min/max/mean/std), `_provenance` - ---- - -### `calculate_azimuthal_mean` - -Compute the azimuthal (radial) mean of a variable around a centre point, producing a -radial profile. Useful for: - -- **Tropical cyclone structure** — radial profiles of wind, pressure, SST -- **Polar vortex analysis** — radial decay from the pole -- **Storm-centred composites** — any feature with approximate radial symmetry - -**Parameters:** +| `inspect_mesh` | Mesh topology and format | +| `inspect_variable` | Variable metadata and statistics | +| `validate_dataset` | NaN/Inf/fill-value checks | +| `calculate_area` | Face area statistics | +| `calculate_zonal_mean` | Latitude-band mean for a face-centered variable | +| `gradient`, `curl`, `divergence`, `azimuthal_mean` | Vector/radial diagnostics | +| `subset_bbox`, `subset_polygon`, `cross_section` | Spatial selections | +| `compare_fields`, `bias`, `rmse`, `pattern_correlation` | Same-grid comparisons | +| `remap_variable`, `regrid_dataset` | UXarray-backed remapping | +| `temporal_mean`, `anomaly` | Time-dimension summaries | +| `ensemble_mean`, `ensemble_spread` | Multi-file ensemble summaries | +| `export` | Write a persisted result or dataset to NetCDF/CSV | + +Common parameters include `grid_path`, `data_path`, `variable_name`, +`target_grid_path`, `data_path_a`, `data_path_b`, `data_paths`, `lon_bounds`, +`lat_bounds`, `method`, `session_id`, `dataset_handle`, `use_remote`, and +`endpoint`. Each operation validates the parameters it requires and returns a +clear error if one is missing. + +Examples: + +```python +run_analysis(operation="inspect_mesh", grid_path="healpix:4") +run_analysis(operation="calculate_area", grid_path="/path/grid.nc") +run_analysis( + operation="calculate_zonal_mean", + grid_path="/path/grid.nc", + data_path="/path/data.nc", + variable_name="temperature", +) +``` -| Name | Type | Description | -|------|------|-------------| -| `grid_path` | `str` | Path to the mesh grid file | -| `data_path` | `str` | Path to the data file | -| `variable_name` | `str` | Face-centered variable to average | -| `center_lon` | `float` | Longitude of centre point (degrees) | -| `center_lat` | `float` | Latitude of centre point (degrees) | -| `outer_radius` | `float` | Maximum radius in great-circle degrees | -| `radius_step` | `float` | Radial bin width in great-circle degrees | +### `plot_dataset` -**Returns:** `variable_name`, `center`, `outer_radius_deg`, `radius_step_deg`, `radii_deg`, `azimuthal_mean_values`, `n_face`, `_provenance` +Render plots through one plotting front door. ---- +Supported `plot_type` values: -## HPC Diagnostics +- `mesh` +- `mesh_geo` +- `variable` +- `zonal_mean` -### `endpoint_status` +Common parameters include `grid_path`, `data_path`, `variable_name`, `width`, +`height`, `cmap`, `vmin`, `vmax`, `title`, `use_remote`, `endpoint`, +`session_id`, and `dataset_handle`. -Check the status of one or all configured HPC endpoints. +### `diagnose_endpoint` -By default this is a **fast, cached manager check** — it queries the Globus -cloud about the endpoint manager process without submitting any tasks. -Use `probe=True` to also submit a lightweight task that confirms a real -scheduler worker responds (takes 15–90 s). +Run endpoint diagnostics with concrete failure guidance. -**Status values:** +Actions: -| Value | Meaning | +| Action | Purpose | |---|---| -| `"registered"` | Manager is running; Slurm/PBS will allocate workers on demand. Normal idle state. | -| `"active"` | Manager running + a probe task confirmed a real worker responded (`probe=True` only). | -| `"offline"` | Manager not running — SSH in and run `globus-compute-endpoint start `. | -| `"unreachable"` | Cannot contact Globus Compute (auth or network error). | -| `"no_endpoint"` | No endpoint configured for this name. | - -**Parameters:** - -| Name | Type | Description | -|------|------|-------------| -| `endpoint` | `str` (optional) | Named endpoint to check; omit for all configured endpoints | -| `force` | `bool` | Bypass cache and re-query the SDK (default: `False`) | -| `probe` | `bool` | Also submit a lightweight task to confirm a worker responds (default: `False`) | -| `probe_timeout_seconds` | `int` | Timeout for worker probe (default: `60`, only used when `probe=True`) | - -**Returns:** `endpoints` (list of rows with `name`, `endpoint_name`, -`endpoint_configured`, `status`, `cached`, `cache_age_seconds`, `node`, -`python`, `slurm_job_id`, `error`), `mode`, `default_endpoint`, `_provenance`. -Raw endpoint UUIDs are read from private local config but are not returned in -public tool payloads. - -**Examples:** +| `status` | Endpoint manager plus optional worker probe | +| `validate` | SDK auth, endpoint reachability, worker probe, optional sample path | +| `probe_path` | Check whether one exact path is readable locally or remotely | -``` -# Fast manager check (always safe to call, results cached 10 s) -endpoint_status() -→ {"endpoints": [{"name": "chrysalis", "status": "registered", ...}], ...} - -# Confirm a real worker actually runs (submits a Slurm job) -endpoint_status(endpoint="chrysalis", probe=True) -→ {"endpoints": [{"name": "chrysalis", "status": "active", - "node": "chr-0497", "python": "3.13.13", ...}], ...} -``` +### `probe_path_access` ---- +Direct convenience path probe for cluster bring-up. This remains separately +registered because it is the safest first command when a new filesystem path is +suspect. -## Remote (HPC) Execution +### `run_workflow` and `resume_workflow` -The core inspection, computation, and plotting tools accept an optional -`use_remote` flag — there are no separate `*_hpc` tool names. When `True`, -the dispatcher offloads the call to a configured Globus Compute endpoint; -when `False` (the default) or when no endpoint is configured, it runs -locally. +Run or resume the canonical persisted workflow: endpoint/path checks, mesh +inspection, variable inspection, validation, area, and zonal mean when valid. -| Name | Type | Description | -|------|------|-------------| -| `use_remote` | `bool` | Execute on a configured Globus Compute endpoint (default: `False`) | -| `endpoint` | `str` (optional) | Named endpoint to target when several are configured | +### `manage_session` -Tools that support `use_remote`: `inspect_mesh`, `inspect_variable`, -`calculate_area`, `calculate_zonal_mean`, `plot_mesh`, `plot_variable`, -`plot_zonal_mean`. Each remote call runs a pre-flight health check before -submitting to avoid hanging on a down endpoint, and falls back to local -execution if the endpoint is unreachable. +Create sessions, register datasets, inspect session state, reset state, and +list operations through one session front door. ---- +Actions: `create`, `register_dataset`, `get`, `reset`, `list_operations`, +`dataset`. -## Scientific Agent +### `get_status` -### `run_scientific_agent` +Read workflow or operation status. -Autonomous four-stage pipeline that inspects a mesh, plans operations, executes them (locally or on HPC), and verifies results. +### `get_result` -**Parameters:** +Inspect a persisted result handle and artifact metadata. -| Name | Type | Description | -|------|------|-------------| -| `file_path` | `str` | Path to mesh file, HPC path, or `healpix:` | -| `data_path` | `str` (optional) | Path to a data file containing variables | -| `variable_name` | `str` (optional) | Specific variable to analyze. If omitted and data is provided, the first face-centered variable is used | +## Remote Execution -**Returns:** `file_path`, `execution_venue`, `reasoning_trace`, `mesh_summary`, `area_results`, `variable_results`, `zonal_mean_results`, `validation_summary`, `verification`, `_provenance` - -See [Scientific Agent](scientific-agent.md) for details on the four-stage loop and auto-routing logic. - ---- +`analyze_dataset`, `run_analysis`, `plot_dataset`, and `probe_path_access` +accept `use_remote=True` and `endpoint="name"` where remote execution applies. +Remote calls submit self-contained functions to a configured Globus Compute +endpoint and preserve provenance. If an endpoint is missing or unhealthy, the +dispatcher either falls back locally or reports a structured readiness error. ## MCP Prompts Prompts are user-invokable slash commands. In Claude Code or Claude Desktop -they appear as `/first_look`, `/vorticity_analysis`, and `/hpc_diagnose`. The -client injects the prompt text into the conversation and the AI calls the -appropriate tools automatically — no need to know tool names or parameter order. - -### `/first_look path` - -Runs the full `get_capabilities` → `analyze_dataset` pipeline on a mesh or -dataset file. Returns topology summary, data quality report, zonal mean -profile, and mesh + variable plots. - -**Argument:** path to a local file or `healpix:`. - ---- - -### `/vorticity_analysis grid_path data_path u_var v_var` - -Runs `calculate_curl` and `calculate_divergence` on the provided wind -components and asks the AI to interpret the atmospheric dynamics — where is -vorticity extreme (cyclones/anticyclones)? Where does convergence signal -rising motion? - -**Arguments:** grid file path, data file path, zonal wind variable name, -meridional wind variable name. - ---- - -### `/hpc_diagnose [endpoint]` - -Runs `endpoint_status` (fast cached check) followed by `validate_hpc_setup` -(deep SDK + remote probe), then guides the user through fixing any failures — -re-authentication, restarting the endpoint manager, fixing the worker -environment. - -**Argument:** named endpoint (`improv`, `ucar`), or omit to check all. +they appear as `/first_look`, `/vorticity_analysis`, and `/hpc_diagnose`. + +- `/first_look path` calls `get_capabilities` and `analyze_dataset`. +- `/vorticity_analysis grid_path data_path u_var v_var` calls + `run_analysis(operation="curl")` and + `run_analysis(operation="divergence")`. +- `/hpc_diagnose [endpoint]` calls + `diagnose_endpoint(action="status")` and + `diagnose_endpoint(action="validate")`. diff --git a/docs/workflows.md b/docs/workflows.md index 3f0961b..7f5d32b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -3,14 +3,14 @@ The repository now includes a first-class persisted workflow surface for multi-step scientific runs: -- `create_session(...)` -- `register_dataset(...)` +- `manage_session(action="create", ...)` +- `manage_session(action="register_dataset", ...)` - `run_workflow(...)` - `resume_workflow(...)` -- `get_workflow_status(...)` -- `get_operation_status(...)` +- `get_status(kind="workflow", ...)` +- `get_status(kind="operation", ...)` -The default workflow template is a deterministic sequence that runs: +Internally, the default workflow template is a deterministic sequence that runs: 1. `validate_hpc_setup` 2. `probe_path_access` diff --git a/src/uxarray_mcp/server.py b/src/uxarray_mcp/server.py index c74f0b0..1448203 100644 --- a/src/uxarray_mcp/server.py +++ b/src/uxarray_mcp/server.py @@ -1,228 +1,92 @@ """UXarray MCP Server - Provides mesh analysis tools for AI agents. -The MCP-registered tool names are kept short and stable. Tools that can run -on either the local machine or a configured Globus Compute endpoint expose a -``use_remote`` flag — there is a single canonical name per tool (no -``*_hpc`` suffix). The dispatcher falls back to local execution -automatically when no endpoint is configured. +The MCP surface is intentionally small. Low-level UXarray capabilities remain +available inside ``uxarray_mcp.tools`` for tests, scripts, and internal +workflows, but MCP clients see intent-shaped front doors instead of dozens of +fine-grained implementation functions. """ from fastmcp import FastMCP from uxarray_mcp.tools import ( analyze_dataset, - calculate_anomaly, - calculate_area, - calculate_azimuthal_mean, - calculate_bias, - calculate_curl, - calculate_divergence, - calculate_ensemble_mean, - calculate_ensemble_spread, - calculate_gradient, - calculate_pattern_correlation, - calculate_rmse, - calculate_temporal_mean, - calculate_zonal_mean, - compare_fields, - create_session, - endpoint_status, - export_to_csv, - export_to_netcdf, - extract_cross_section, + diagnose_endpoint, get_capabilities, - get_execution_mode, - get_operation_status, - get_result_handle, - get_session_state, - get_workflow_status, - inspect_mesh, - inspect_variable, - list_datasets, - list_operations, - plot_mesh, - plot_mesh_geo, - plot_variable, - plot_zonal_mean, + get_result, + get_status, + manage_session, + plot_dataset, probe_path_access, - register_dataset, - regrid_dataset, - remap_variable, - reset_session_state, resume_workflow, - run_scientific_agent, + run_analysis, run_workflow, - set_execution_mode, - subset_bbox, - subset_polygon, - validate_dataset, - validate_hpc_setup, - write_result, ) -# Initialize the MCP server mcp = FastMCP("uxarray-mcp-server") -# Tool discovery — always call this first with a new dataset +# Discovery and first-look analysis. mcp.tool()(get_capabilities) - -# Deterministic one-shot analysis — full first-look pipeline in a single call mcp.tool()(analyze_dataset) -# Autonomous scientific agent — Analyze → Plan → Execute → Verify -mcp.tool()(run_scientific_agent) -mcp.tool()(run_workflow) -mcp.tool()(resume_workflow) -mcp.tool()(get_workflow_status) -mcp.tool()(create_session) -mcp.tool()(register_dataset) -mcp.tool()(get_session_state) -mcp.tool()(reset_session_state) -mcp.tool()(get_result_handle) -mcp.tool()(get_operation_status) -mcp.tool()(list_operations) - -# Core inspection + computation tools. Each accepts ``use_remote`` / -# ``endpoint`` and falls back to local execution when no endpoint is configured. -mcp.tool()(inspect_mesh) -mcp.tool()(inspect_variable) -mcp.tool()(calculate_area) -mcp.tool()(calculate_zonal_mean) -mcp.tool()(validate_dataset) -mcp.tool()(list_datasets) - -# Visualization tools — same dispatcher pattern. -mcp.tool()(plot_mesh) -mcp.tool()(plot_mesh_geo) -mcp.tool()(plot_variable) -mcp.tool()(plot_zonal_mean) - -# Vector calculus — gradient, curl (vorticity), divergence, azimuthal mean -mcp.tool()(calculate_gradient) -mcp.tool()(calculate_curl) -mcp.tool()(calculate_divergence) -mcp.tool()(calculate_azimuthal_mean) - -# Analysis extensions -mcp.tool()(subset_bbox) -mcp.tool()(subset_polygon) -mcp.tool()(extract_cross_section) -mcp.tool()(compare_fields) -mcp.tool()(calculate_bias) -mcp.tool()(calculate_rmse) -mcp.tool()(calculate_pattern_correlation) -mcp.tool()(remap_variable) -mcp.tool()(regrid_dataset) -mcp.tool()(calculate_temporal_mean) -mcp.tool()(calculate_anomaly) -mcp.tool()(calculate_ensemble_mean) -mcp.tool()(calculate_ensemble_spread) -mcp.tool()(export_to_netcdf) -mcp.tool()(export_to_csv) -mcp.tool()(write_result) - -# Execution mode + diagnostics -mcp.tool()(get_execution_mode) -mcp.tool()(endpoint_status) +# Intent-shaped operation dispatch. These tools fan out to the lower-level +# analysis, plotting, state, and diagnostic functions. +mcp.tool()(run_analysis) +mcp.tool()(plot_dataset) +mcp.tool()(diagnose_endpoint) mcp.tool()(probe_path_access) -mcp.tool()(set_execution_mode) -mcp.tool()(validate_hpc_setup) -# --------------------------------------------------------------------------- -# MCP Prompts — user-invokable slash commands that guide common workflows. -# Each prompt returns a structured message the client injects into the -# conversation; the AI then calls the appropriate tools. -# --------------------------------------------------------------------------- +# Stateful workflow/session front doors. +mcp.tool()(run_workflow) +mcp.tool()(resume_workflow) +mcp.tool()(get_status) +mcp.tool()(get_result) +mcp.tool()(manage_session) @mcp.prompt() def first_look(path: str) -> str: - """Run the full first-look analysis pipeline on a mesh or dataset. - - Pass a local file path (or HEALPix spec like healpix:4). The assistant - will call get_capabilities, then analyze_dataset to inspect topology, - validate data quality, compute area statistics, zonal mean, and produce - mesh and variable plots — all in one shot. - - Parameters - ---------- - path : str - Path to the mesh or data file, e.g. /data/grid.nc or healpix:4. - """ + """Run the full first-look analysis pipeline on a mesh or dataset.""" return ( f"Run a complete first-look analysis on `{path}`.\n\n" "Steps:\n" - '1. Call `get_capabilities` with `grid_path="{path}"` to discover ' - "what tools apply.\n" - '2. Call `analyze_dataset` with `file_path="{path}"` to run the full ' - "pipeline: inspect_mesh → validate_dataset → inspect_variable → " - "calculate_area → calculate_zonal_mean → plot_mesh → plot_variable.\n" - "3. Summarise the mesh topology, any data quality issues, and the " - "zonal mean profile. Show the plots inline.\n" - "4. List the recommended next steps from the result." - ).format(path=path) + f'1. Call `get_capabilities` with `grid_path="{path}"` to discover ' + "what operations apply.\n" + f'2. Call `analyze_dataset` with `grid_path="{path}"` to run the full ' + "first-look pipeline.\n" + "3. Summarise topology, data quality issues, selected variable, area " + "statistics, zonal mean, plots, and recommended next steps." + ) @mcp.prompt() def vorticity_analysis(grid_path: str, data_path: str, u_var: str, v_var: str) -> str: - """Compute and interpret relative vorticity and wind divergence. - - Runs calculate_curl (vorticity ζ = ∂v/∂x − ∂u/∂y) and - calculate_divergence (∂u/∂x + ∂v/∂y) on the provided wind components, - then asks the assistant to interpret the atmospheric dynamics. - - Parameters - ---------- - grid_path : str - Path to the mesh grid file. - data_path : str - Path to the data file containing wind components. - u_var : str - Zonal wind variable name (e.g. uReconstructZonal). - v_var : str - Meridional wind variable name (e.g. uReconstructMeridional). - """ + """Compute and interpret relative vorticity and wind divergence.""" return ( - f"Analyse the vorticity and divergence of the wind field in `{data_path}`.\n\n" - f'1. Call `calculate_curl` with grid_path="{grid_path}", ' - f'data_path="{data_path}", u_variable="{u_var}", v_variable="{v_var}".\n' - f"2. Call `calculate_divergence` with the same arguments.\n" - "3. Interpret the results:\n" - " - Where is vorticity largest/smallest? What does that indicate " - "(cyclonic vs anticyclonic flow)?\n" - " - Where is divergence strongly negative (convergence)? That " - "indicates rising motion and potential convection.\n" - " - Report the global min/max/mean/std for both fields.\n" - "4. Suggest follow-up analysis (e.g. subset_bbox over regions of " - "extreme vorticity, plot_variable of a derived field)." + f"Analyse vorticity and divergence for `{data_path}`.\n\n" + "1. Call `run_analysis` with " + f'operation="curl", grid_path="{grid_path}", data_path="{data_path}", ' + f'u_variable="{u_var}", v_variable="{v_var}".\n' + "2. Call `run_analysis` with " + f'operation="divergence", grid_path="{grid_path}", ' + f'data_path="{data_path}", u_variable="{u_var}", ' + f'v_variable="{v_var}".\n' + "3. Interpret the min/max/mean/std values and identify follow-up " + "plots or regional subsets." ) @mcp.prompt() def hpc_diagnose(endpoint: str = "") -> str: - """Diagnose the HPC endpoint connection and configuration. - - Runs endpoint_status, then validate_hpc_setup, and guides the user - through fixing any issues found. - - Parameters - ---------- - endpoint : str, optional - Named endpoint to diagnose (e.g. "improv", "ucar"). Leave blank - to check all configured endpoints. - """ - ep_arg = f'endpoint="{endpoint}"' if endpoint else "" + """Diagnose the HPC endpoint connection and configuration.""" + ep = f', endpoint="{endpoint}"' if endpoint else "" return ( "Diagnose the HPC Globus Compute configuration.\n\n" - f"1. Call `endpoint_status`({ep_arg}) to get a fast cached status " - "summary of all configured endpoints.\n" - f"2. Call `validate_hpc_setup`({ep_arg}) to run a deeper check: " - "SDK auth, endpoint manager reachability, and a remote no-op probe.\n" - "3. If any check fails, explain what the error means and what the " - "user should do to fix it (re-authenticate, restart the endpoint " - "manager, check the worker environment, etc.).\n" - "4. If everything passes, confirm the endpoint is ready for " - "use_remote=True tool calls." + f'1. Call `diagnose_endpoint(action="status"{ep})` for endpoint ' + "manager and worker status.\n" + f'2. Call `diagnose_endpoint(action="validate"{ep})` for SDK auth, ' + "manager reachability, and a remote no-op probe.\n" + "3. Explain failures as concrete next actions: re-authenticate, " + "restart the endpoint, fix worker environment, or probe a path." ) diff --git a/src/uxarray_mcp/tools/__init__.py b/src/uxarray_mcp/tools/__init__.py index 463b348..8e083ae 100644 --- a/src/uxarray_mcp/tools/__init__.py +++ b/src/uxarray_mcp/tools/__init__.py @@ -25,6 +25,14 @@ set_execution_mode, validate_hpc_setup, ) +from .frontdoor import ( + diagnose_endpoint, + get_result, + get_status, + manage_session, + plot_dataset, + run_analysis, +) from .inspection import validate_dataset from .orchestration import analyze_dataset @@ -112,4 +120,10 @@ "probe_path_access", "set_execution_mode", "validate_hpc_setup", + "run_analysis", + "plot_dataset", + "diagnose_endpoint", + "manage_session", + "get_status", + "get_result", ] diff --git a/src/uxarray_mcp/tools/capabilities.py b/src/uxarray_mcp/tools/capabilities.py index 38b1dba..9f11f8f 100644 --- a/src/uxarray_mcp/tools/capabilities.py +++ b/src/uxarray_mcp/tools/capabilities.py @@ -131,25 +131,27 @@ def get_capabilities( else: location = "other" - # Per-variable applicable tools - applicable_mcp: List[str] = ["inspect_variable", "validate_dataset"] + # Per-variable applicable front-door operations. + applicable_mcp: List[str] = [ + "run_analysis:inspect_variable", + "run_analysis:validate_dataset", + ] applicable_uxarray: List[str] = [] if location == "faces": applicable_mcp += [ - "calculate_zonal_mean", - "subset_bbox", - "subset_polygon", - "extract_cross_section", - "compare_fields", - "remap_variable", - "regrid_dataset", - "calculate_temporal_mean", - "calculate_anomaly", - "calculate_ensemble_mean", - "calculate_ensemble_spread", - "export_to_netcdf", - "export_to_csv", + "run_analysis:calculate_zonal_mean", + "run_analysis:subset_bbox", + "run_analysis:subset_polygon", + "run_analysis:cross_section", + "run_analysis:compare_fields", + "run_analysis:remap_variable", + "run_analysis:regrid_dataset", + "run_analysis:temporal_mean", + "run_analysis:anomaly", + "run_analysis:ensemble_mean", + "run_analysis:ensemble_spread", + "run_analysis:export", ] applicable_uxarray += [ "var.zonal_mean()", @@ -188,116 +190,38 @@ def get_capabilities( } ) - # --- MCP Server tool filtering --- + # --- MCP Server front-door tool filtering --- gp = f'"{grid_path}"' dp = f', "{data_path}"' if data_path else "" mcp_tools = [ { - "name": "inspect_mesh", + "name": "get_capabilities", "applicable": True, - "reason": "Always available — inspects topology and format of any mesh.", - "call_example": f"inspect_mesh({gp})", + "reason": "Always available — discovers topology, variables, and recommended operations.", + "call_example": f"get_capabilities({gp}{dp})", }, { - "name": "inspect_variable", - "applicable": data_path is not None, - "reason": ( - "Available — inspects all variables in the dataset." - if data_path - else "Requires a data file (data_path not provided)." - ), - "call_example": f"inspect_variable({gp}{dp})", - }, - { - "name": "calculate_area", - "applicable": has_faces, - "reason": ( - f"Available — mesh has {n_face:,} faces for area calculation." - if has_faces - else "Not applicable — no faces found (point cloud or node-only mesh)." - ), - "call_example": f"calculate_area({gp})", - }, - { - "name": "calculate_zonal_mean", - "applicable": has_face_centered_vars, - "reason": ( - f"Available — face-centered variables found: {face_centered_var_names}." - if has_face_centered_vars - else ( - "Requires face-centered data — provide data_path with face-centered variables." - if data_path is None - else "Not applicable — no face-centered variables found. Zonal mean requires data mapped to faces." - ) - ), - "call_example": f'calculate_zonal_mean({gp}{dp}, variable_name="...")', + "name": "analyze_dataset", + "applicable": True, + "reason": "Runs the deterministic first-look pipeline: inspect, validate, area, zonal mean, and plots when possible.", + "call_example": f"analyze_dataset(grid_path={gp}{dp})", }, { - "name": "validate_dataset", - "applicable": data_path is not None, - "reason": ( - "Available — checks for NaN, Inf, and fill value issues." - if data_path - else "Requires a data file (data_path not provided)." - ), - "call_example": f"validate_dataset({gp}{dp})", + "name": "run_analysis", + "applicable": True, + "reason": "Runs one named operation such as inspect_mesh, calculate_area, calculate_zonal_mean, subset, compare, remap, temporal, ensemble, or export.", + "call_example": f'run_analysis(operation="calculate_area", grid_path={gp})', }, { - "name": "plot_mesh", + "name": "plot_dataset", "applicable": has_faces, "reason": ( - "Available — renders a wireframe visualization of the mesh." + "Renders mesh, geographic mesh, variable, or zonal-mean plots." if has_faces - else "Not applicable — no faces found to visualize as a mesh." - ), - "call_example": f"plot_mesh({gp})", - }, - { - "name": "plot_variable", - "applicable": has_face_centered_vars, - "reason": ( - f"Available — face-centered variables found: {face_centered_var_names}." - if has_face_centered_vars - else ( - "Requires face-centered data — provide data_path with face-centered variables." - if data_path is None - else "Not applicable — no face-centered variables found for polygon plotting." - ) - ), - "call_example": f'plot_variable({gp}{dp}, variable_name="...")', - }, - { - "name": "plot_zonal_mean", - "applicable": has_face_centered_vars, - "reason": ( - f"Available — face-centered variables found: {face_centered_var_names}." - if has_face_centered_vars - else ( - "Requires face-centered data — provide data_path with face-centered variables." - if data_path is None - else "Not applicable — no face-centered variables found for zonal plotting." - ) + else "Requires mesh faces for plot types other than point-style future work." ), - "call_example": f'plot_zonal_mean({gp}{dp}, variable_name="...")', - }, - { - "name": "create_session", - "applicable": True, - "reason": ( - "Available — creates a lightweight scientific session for " - "datasets, results, workflows, and progress tracking." - ), - "call_example": 'create_session(name="analysis")', - }, - { - "name": "register_dataset", - "applicable": True, - "reason": ( - "Available — registers a grid/data pair in a session so later " - "calls can use dataset handles instead of repeated file paths." - ), - "call_example": f'register_dataset(session_id="...", grid_path={gp}{dp})', + "call_example": f'plot_dataset(plot_type="mesh", grid_path={gp})', }, { "name": "run_workflow", @@ -309,167 +233,39 @@ def get_capabilities( "call_example": f'run_workflow(file_path={gp}{dp}, variable_name="...")', }, { - "name": "subset_bbox", - "applicable": has_faces, - "reason": ( - "Available — crops a mesh or face-centered field by lon/lat bounds." - if has_faces - else "Not applicable — no faces found for spatial subsetting." - ), - "call_example": ( - f"subset_bbox(lon_bounds=[0, 10], lat_bounds=[-5, 5], grid_path={gp}{dp})" - ), - }, - { - "name": "subset_polygon", - "applicable": has_faces, - "reason": ( - "Available — selects face centers within a polygon footprint." - if has_faces - else "Not applicable — no faces found for polygon selection." - ), - "call_example": ( - f"subset_polygon(polygon_lon_lat=[[0, 0], [10, 0], [10, 5]], " - f"grid_path={gp}{dp})" - ), - }, - { - "name": "extract_cross_section", - "applicable": has_faces, - "reason": ( - "Available — extracts constant-latitude or constant-longitude slices." - if has_faces - else "Not applicable — no faces found for cross-sections." - ), - "call_example": ( - f"extract_cross_section(latitude=0.0, grid_path={gp}{dp})" - ), - }, - { - "name": "compare_fields", - "applicable": has_face_centered_vars, - "reason": ( - "Available — compares same-grid fields and persists a difference field." - if has_face_centered_vars - else "Requires same-grid face-centered data for v1 comparisons." - ), - "call_example": ( - f'compare_fields(variable_name="...", data_path_a="...", ' - f'data_path_b="...", grid_path={gp})' - ), - }, - { - "name": "remap_variable", - "applicable": has_face_centered_vars, - "reason": ( - "Available — remaps one face-centered variable to a target grid." - if has_face_centered_vars - else "Requires a face-centered variable to remap." - ), - "call_example": ( - f'remap_variable(target_grid_path="target.nc", variable_name="...", ' - f"grid_path={gp}{dp})" - ), - }, - { - "name": "regrid_dataset", - "applicable": has_face_centered_vars, - "reason": ( - "Available — remaps selected face-centered variables to a target grid." - if has_face_centered_vars - else "Requires face-centered variables to regrid a dataset." - ), - "call_example": ( - f'regrid_dataset(target_grid_path="target.nc", grid_path={gp}{dp})' - ), - }, - { - "name": "calculate_temporal_mean", - "applicable": data_path is not None, - "reason": ( - "Available when the requested variable includes a time dimension." - if data_path - else "Requires a data file with a time-aware variable." - ), - "call_example": ( - 'calculate_temporal_mean(data_path="data.nc", ' - 'variable_name="temperature")' - ), - }, - { - "name": "calculate_anomaly", - "applicable": data_path is not None, - "reason": ( - "Available when the requested variable includes a time dimension." - if data_path - else "Requires a data file with a time-aware variable." - ), - "call_example": ( - 'calculate_anomaly(data_path="data.nc", variable_name="temperature")' - ), - }, - { - "name": "calculate_ensemble_mean", - "applicable": data_path is not None, - "reason": ( - "Available — computes a mean across multiple explicitly provided files." - if data_path - else "Requires one or more data files with a common variable." - ), - "call_example": ( - 'calculate_ensemble_mean(variable_name="temperature", ' - 'data_paths=["run1.nc", "run2.nc"])' - ), + "name": "resume_workflow", + "applicable": True, + "reason": "Resumes a persisted workflow that stopped after a failed or skipped step.", + "call_example": 'resume_workflow(workflow_id="workflow_...")', }, { - "name": "calculate_ensemble_spread", - "applicable": data_path is not None, - "reason": ( - "Available — computes ensemble spread across multiple files." - if data_path - else "Requires one or more data files with a common variable." - ), - "call_example": ( - 'calculate_ensemble_spread(variable_name="temperature", ' - 'data_paths=["run1.nc", "run2.nc"])' - ), + "name": "get_status", + "applicable": True, + "reason": "Reads workflow or operation status without exposing separate status tools.", + "call_example": 'get_status(kind="workflow", workflow_id="workflow_...")', }, { - "name": "export_to_netcdf", + "name": "get_result", "applicable": True, - "reason": ( - "Available — exports a persisted result handle or session dataset to NetCDF." - ), - "call_example": ( - 'export_to_netcdf(output_path="out.nc", result_handle="result_...")' - ), + "reason": "Inspects a persisted result handle and artifact metadata.", + "call_example": 'get_result(result_handle="result_...")', }, { - "name": "export_to_csv", + "name": "manage_session", "applicable": True, - "reason": ( - "Available — exports a persisted result handle or session dataset to CSV." - ), - "call_example": ( - 'export_to_csv(output_path="out.csv", result_handle="result_...")' - ), + "reason": "Creates sessions, registers datasets, reads session state, resets state, and lists operations.", + "call_example": f'manage_session(action="register_dataset", session_id="...", grid_path={gp}{dp})', }, { - "name": "validate_hpc_setup", + "name": "diagnose_endpoint", "applicable": True, - "reason": ( - "Available — validates local Globus auth, endpoint status, and " - "optionally submits a tiny remote probe to catch scheduler/bootstrap failures." - ), - "call_example": "validate_hpc_setup()", + "reason": "Runs endpoint status, setup validation, or path probes with concrete failure guidance.", + "call_example": 'diagnose_endpoint(action="status", endpoint="improv")', }, { "name": "probe_path_access", "applicable": True, - "reason": ( - "Available — proves whether the exact target path is readable " - "before debugging UXarray parsing or scheduler behavior." - ), + "reason": "Direct path-readability probe kept as a convenience for cluster bring-up.", "call_example": 'probe_path_access("/path/to/file.nc", use_remote=True)', }, ] @@ -487,13 +283,11 @@ def get_capabilities( ) for tool in mcp_tools: if tool["name"] in { - "inspect_mesh", - "inspect_variable", - "calculate_area", - "calculate_zonal_mean", - "plot_mesh", - "plot_variable", - "plot_zonal_mean", + "analyze_dataset", + "run_analysis", + "plot_dataset", + "diagnose_endpoint", + "probe_path_access", }: tool["reason"] = str(tool["reason"]) + remote_note diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py new file mode 100644 index 0000000..3f18655 --- /dev/null +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -0,0 +1,499 @@ +"""High-level MCP front-door tools. + +These functions intentionally group many implementation capabilities behind a +small public tool surface. The lower-level functions remain available as the +Python API, but MCP clients get fewer, intent-shaped choices. +""" + +from __future__ import annotations + +from typing import Any + + +def _require(value: Any, name: str, operation: str) -> Any: + if value is None: + raise ValueError(f"{operation!r} requires {name}.") + return value + + +def run_analysis( + operation: str, + grid_path: str | None = None, + data_path: str | None = None, + variable_name: str | None = None, + target_grid_path: str | None = None, + data_path_a: str | None = None, + data_path_b: str | None = None, + data_paths: list[str] | None = None, + u_variable: str | None = None, + v_variable: str | None = None, + lon_bounds: list[float] | None = None, + lat_bounds: list[float] | None = None, + polygon_lon_lat: list[list[float]] | None = None, + latitude: float | None = None, + longitude: float | None = None, + center_lon: float | None = None, + center_lat: float | None = None, + outer_radius: float | None = None, + radius_step: float | None = None, + method: str = "nearest_neighbor", + remap_to: str = "faces", + groupby: str | None = None, + baseline: str = "temporal_mean", + output_path: str | None = None, + output_format: str = "netcdf", + result_handle: str | None = None, + session_id: str | None = None, + dataset_handle: str | None = None, + result_name: str | None = None, + use_remote: bool = False, + endpoint: str | None = None, +) -> dict[str, Any]: + """Run one analysis operation by intent instead of exposing many tools. + + Supported operations: + ``inspect_mesh``, ``inspect_variable``, ``validate_dataset``, + ``calculate_area``, ``calculate_zonal_mean``, ``gradient``, ``curl``, + ``divergence``, ``azimuthal_mean``, ``subset_bbox``, ``subset_polygon``, + ``cross_section``, ``compare_fields``, ``bias``, ``rmse``, + ``pattern_correlation``, ``remap_variable``, ``regrid_dataset``, + ``temporal_mean``, ``anomaly``, ``ensemble_mean``, ``ensemble_spread``, + and ``export``. + """ + from uxarray_mcp.tools.advanced import ( + calculate_anomaly, + calculate_bias, + calculate_ensemble_mean, + calculate_ensemble_spread, + calculate_pattern_correlation, + calculate_rmse, + calculate_temporal_mean, + compare_fields, + extract_cross_section, + regrid_dataset, + remap_variable, + subset_bbox, + subset_polygon, + write_result, + ) + from uxarray_mcp.tools.inspection import validate_dataset + from uxarray_mcp.tools.remote_tools import ( + calculate_area, + calculate_zonal_mean, + inspect_mesh, + inspect_variable, + ) + from uxarray_mcp.tools.vector_calc import ( + calculate_azimuthal_mean, + calculate_curl, + calculate_divergence, + calculate_gradient, + ) + + op = operation.strip().lower().replace("-", "_") + + if op == "inspect_mesh": + return inspect_mesh( + _require(grid_path, "grid_path", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "inspect_variable": + return inspect_variable( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + variable_name, + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "validate_dataset": + return validate_dataset( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + ) + if op == "calculate_area": + return calculate_area( + _require(grid_path, "grid_path", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "calculate_zonal_mean": + return calculate_zonal_mean( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(variable_name, "variable_name", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "gradient": + return calculate_gradient( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(variable_name, "variable_name", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "curl": + return calculate_curl( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(u_variable, "u_variable", op), + _require(v_variable, "v_variable", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "divergence": + return calculate_divergence( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(u_variable, "u_variable", op), + _require(v_variable, "v_variable", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "azimuthal_mean": + return calculate_azimuthal_mean( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(variable_name, "variable_name", op), + _require(center_lon, "center_lon", op), + _require(center_lat, "center_lat", op), + _require(outer_radius, "outer_radius", op), + _require(radius_step, "radius_step", op), + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + ) + if op == "subset_bbox": + return subset_bbox( + lon_bounds=_require(lon_bounds, "lon_bounds", op), + lat_bounds=_require(lat_bounds, "lat_bounds", op), + grid_path=grid_path, + data_path=data_path, + variable_name=variable_name, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) + if op == "subset_polygon": + return subset_polygon( + polygon_lon_lat=_require(polygon_lon_lat, "polygon_lon_lat", op), + grid_path=grid_path, + data_path=data_path, + variable_name=variable_name, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) + if op == "cross_section": + return extract_cross_section( + latitude=latitude, + longitude=longitude, + grid_path=grid_path, + data_path=data_path, + variable_name=variable_name, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) + if op == "compare_fields": + return compare_fields( + variable_name=_require(variable_name, "variable_name", op), + data_path_a=_require(data_path_a, "data_path_a", op), + data_path_b=_require(data_path_b, "data_path_b", op), + grid_path=grid_path, + session_id=session_id, + result_name=result_name, + ) + if op == "bias": + return calculate_bias( + variable_name=_require(variable_name, "variable_name", op), + data_path_a=_require(data_path_a, "data_path_a", op), + data_path_b=_require(data_path_b, "data_path_b", op), + grid_path=grid_path, + ) + if op == "rmse": + return calculate_rmse( + variable_name=_require(variable_name, "variable_name", op), + data_path_a=_require(data_path_a, "data_path_a", op), + data_path_b=_require(data_path_b, "data_path_b", op), + grid_path=grid_path, + ) + if op == "pattern_correlation": + return calculate_pattern_correlation( + variable_name=_require(variable_name, "variable_name", op), + data_path_a=_require(data_path_a, "data_path_a", op), + data_path_b=_require(data_path_b, "data_path_b", op), + grid_path=grid_path, + ) + if op == "remap_variable": + return remap_variable( + target_grid_path=_require(target_grid_path, "target_grid_path", op), + variable_name=_require(variable_name, "variable_name", op), + grid_path=grid_path, + data_path=data_path, + method=method, + remap_to=remap_to, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) + if op == "regrid_dataset": + return regrid_dataset( + target_grid_path=_require(target_grid_path, "target_grid_path", op), + grid_path=grid_path, + data_path=data_path, + variable_names=[variable_name] if variable_name else None, + method=method, + remap_to=remap_to, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) + if op == "temporal_mean": + return calculate_temporal_mean( + data_path=_require(data_path, "data_path", op), + variable_name=_require(variable_name, "variable_name", op), + groupby=groupby, + session_id=session_id, + result_name=result_name, + ) + if op == "anomaly": + return calculate_anomaly( + data_path=_require(data_path, "data_path", op), + variable_name=_require(variable_name, "variable_name", op), + baseline=baseline, + session_id=session_id, + result_name=result_name, + ) + if op == "ensemble_mean": + return calculate_ensemble_mean( + variable_name=_require(variable_name, "variable_name", op), + data_paths=_require(data_paths, "data_paths", op), + session_id=session_id, + result_name=result_name, + ) + if op == "ensemble_spread": + return calculate_ensemble_spread( + variable_name=_require(variable_name, "variable_name", op), + data_paths=_require(data_paths, "data_paths", op), + session_id=session_id, + result_name=result_name, + ) + if op == "export": + return write_result( + output_path=_require(output_path, "output_path", op), + format=output_format, + result_handle=result_handle, + session_id=session_id, + dataset_handle=dataset_handle, + variable_name=variable_name, + ) + + raise ValueError(f"Unsupported analysis operation {operation!r}.") + + +def plot_dataset( + plot_type: str, + grid_path: str | None = None, + data_path: str | None = None, + variable_name: str | None = None, + width: int = 800, + height: int = 400, + cmap: str = "viridis", + vmin: float | None = None, + vmax: float | None = None, + title: str | None = None, + time_index: int = 0, + lat_spec: tuple | float | list[Any] | None = None, + conservative: bool = False, + line_color: str = "#1f77b4", + lon_bounds: list[float] | None = None, + lat_bounds: list[float] | None = None, + use_remote: bool = False, + endpoint: str | None = None, + session_id: str | None = None, + dataset_handle: str | None = None, +) -> list[Any]: + """Render mesh, geographic mesh, variable, or zonal-mean plots.""" + from uxarray_mcp.tools.plotting import plot_mesh_geo + from uxarray_mcp.tools.remote_tools import plot_mesh, plot_variable, plot_zonal_mean + + kind = plot_type.strip().lower().replace("-", "_") + if kind == "mesh": + return plot_mesh( + grid_path=grid_path, + width=width, + height=height, + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + dataset_handle=dataset_handle, + ) + if kind == "mesh_geo": + return plot_mesh_geo( + grid_path=grid_path, + width=width, + height=height, + lon_bounds=lon_bounds, + lat_bounds=lat_bounds, + session_id=session_id, + dataset_handle=dataset_handle, + ) + if kind == "variable": + return plot_variable( + grid_path=grid_path, + data_path=data_path, + variable_name=variable_name, + width=width, + height=height, + cmap=cmap, + vmin=vmin, + vmax=vmax, + title=title, + time_index=time_index, + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + dataset_handle=dataset_handle, + ) + if kind == "zonal_mean": + return plot_zonal_mean( + grid_path=grid_path, + data_path=data_path, + variable_name=variable_name, + width=width, + height=height, + lat_spec=lat_spec, + conservative=conservative, + line_color=line_color, + title=title, + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + dataset_handle=dataset_handle, + ) + raise ValueError("plot_type must be one of: mesh, mesh_geo, variable, zonal_mean.") + + +def diagnose_endpoint( + action: str = "status", + endpoint: str | None = None, + file_path: str | None = None, + use_remote: bool = True, + inspect_netcdf: bool = True, + probe_timeout_seconds: int = 60, +) -> dict[str, Any]: + """Diagnose endpoint status, setup, or path readability.""" + from uxarray_mcp.tools.execution_control import ( + endpoint_status, + probe_path_access, + validate_hpc_setup, + ) + + mode = action.strip().lower().replace("-", "_") + if mode == "status": + return endpoint_status( + endpoint=endpoint, + force=True, + probe=True, + probe_timeout_seconds=probe_timeout_seconds, + ) + if mode == "validate": + return validate_hpc_setup( + run_remote_probe=True, + probe_timeout_seconds=probe_timeout_seconds, + sample_path=file_path, + endpoint=endpoint, + ) + if mode == "probe_path": + return probe_path_access( + _require(file_path, "file_path", mode), + use_remote=use_remote, + inspect_netcdf=inspect_netcdf, + endpoint=endpoint, + ) + raise ValueError("action must be one of: status, validate, probe_path.") + + +def manage_session( + action: str, + session_id: str | None = None, + name: str | None = None, + grid_path: str | None = None, + data_path: str | None = None, + dataset_handle: str | None = None, + clear_artifacts: bool = False, +) -> dict[str, Any]: + """Create, register, inspect, reset, or list session-scoped state.""" + from uxarray_mcp.tools.stateful import ( + create_session, + get_session_state, + list_operations, + register_dataset, + reset_session_state, + ) + + mode = action.strip().lower().replace("-", "_") + if mode == "create": + return create_session(name=name) + if mode == "register_dataset": + return register_dataset( + session_id=_require(session_id, "session_id", mode), + grid_path=_require(grid_path, "grid_path", mode), + data_path=data_path, + name=name, + ) + if mode == "get": + return get_session_state(_require(session_id, "session_id", mode)) + if mode == "reset": + return reset_session_state( + _require(session_id, "session_id", mode), + clear_artifacts=clear_artifacts, + ) + if mode == "list_operations": + return list_operations(session_id=session_id) + if mode == "dataset": + state = get_session_state(_require(session_id, "session_id", mode)) + handle = _require(dataset_handle, "dataset_handle", mode) + dataset = state.get("datasets", {}).get(handle) + if dataset is None: + raise FileNotFoundError(f"Dataset handle {handle!r} not found.") + return { + "dataset_handle": handle, + "dataset": dataset, + "_provenance": state["_provenance"], + } + raise ValueError( + "action must be one of: create, register_dataset, get, reset, list_operations, dataset." + ) + + +def get_status( + kind: str, + workflow_id: str | None = None, + operation_id: str | None = None, +) -> dict[str, Any]: + """Return workflow or operation status.""" + from uxarray_mcp.tools.stateful import get_operation_status, get_workflow_status + + mode = kind.strip().lower() + if mode == "workflow": + return get_workflow_status(_require(workflow_id, "workflow_id", mode)) + if mode == "operation": + return get_operation_status(_require(operation_id, "operation_id", mode)) + raise ValueError("kind must be one of: workflow, operation.") + + +def get_result(result_handle: str) -> dict[str, Any]: + """Inspect a persisted result handle and artifact metadata.""" + from uxarray_mcp.tools.stateful import get_result_handle + + return get_result_handle(result_handle) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 0d66b09..a6c4094 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -53,36 +53,36 @@ def test_mcp_tools_have_required_fields(self): assert "call_example" in tool, f"Missing 'call_example' in {tool}" assert isinstance(tool["applicable"], bool) - def test_inspect_mesh_always_applicable(self): - """inspect_mesh is always applicable regardless of data.""" + def test_run_analysis_always_applicable(self): + """run_analysis is the front door for one-off operations.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["inspect_mesh"]["applicable"] is True + assert tools["run_analysis"]["applicable"] is True - def test_calculate_area_applicable_with_faces(self): - """calculate_area is applicable when mesh has faces.""" + def test_analyze_dataset_applicable_with_faces(self): + """analyze_dataset is the first-look front door.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["calculate_area"]["applicable"] is True + assert tools["analyze_dataset"]["applicable"] is True - def test_inspect_variable_not_applicable_without_data(self): - """inspect_variable requires data_path.""" + def test_run_analysis_documents_inspection_operations(self): + """Variable inspection is now an operation under run_analysis.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["inspect_variable"]["applicable"] is False - assert "data_path" in tools["inspect_variable"]["reason"].lower() + assert "inspect_mesh" in tools["run_analysis"]["reason"] - def test_zonal_mean_not_applicable_without_data(self): - """calculate_zonal_mean requires face-centered data.""" + def test_zonal_mean_is_not_a_separate_public_tool(self): + """Zonal mean is dispatched through run_analysis.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["calculate_zonal_mean"]["applicable"] is False + assert "calculate_zonal_mean" not in tools + assert "run_analysis" in tools - def test_validate_dataset_not_applicable_without_data(self): - """validate_dataset requires data_path.""" + def test_validate_dataset_is_run_analysis_operation(self): + """Validation is dispatched through run_analysis/analyze_dataset.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["validate_dataset"]["applicable"] is False + assert "validate" in tools["analyze_dataset"]["reason"] def test_probe_path_access_is_always_available(self): """probe_path_access should be surfaced for bring-up on any dataset.""" @@ -90,11 +90,11 @@ def test_probe_path_access_is_always_available(self): tools = {t["name"]: t for t in result["mcp_server_tools"]} assert tools["probe_path_access"]["applicable"] is True - def test_session_and_workflow_tools_are_surfaced(self): - """Stateful orchestration tools should be discoverable without data.""" + def test_session_and_workflow_front_doors_are_surfaced(self): + """Stateful orchestration should be discoverable without low-level tools.""" result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["create_session"]["applicable"] is True + assert tools["manage_session"]["applicable"] is True assert tools["run_workflow"]["applicable"] is True def test_uxarray_capabilities_structure(self): @@ -154,8 +154,8 @@ def test_grid_file(self, synthetic_mesh_file): result = get_capabilities(synthetic_mesh_file) assert result["grid_summary"]["has_faces"] is True tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["inspect_mesh"]["applicable"] is True - assert tools["calculate_area"]["applicable"] is True + assert tools["run_analysis"]["applicable"] is True + assert tools["analyze_dataset"]["applicable"] is True def test_remote_hint_omitted_without_endpoint(self): """Remote-execution hint is omitted when no endpoint is configured.""" @@ -165,11 +165,7 @@ def test_remote_hint_omitted_without_endpoint(self): result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - for name in ( - "inspect_mesh", - "calculate_area", - "plot_mesh", - ): + for name in ("analyze_dataset", "run_analysis", "plot_dataset"): assert name in tools assert "use_remote" not in tools[name]["reason"] @@ -181,15 +177,7 @@ def test_remote_hint_shown_with_endpoint(self): result = get_capabilities("healpix:2") tools = {t["name"]: t for t in result["mcp_server_tools"]} - for name in ( - "inspect_mesh", - "inspect_variable", - "calculate_area", - "calculate_zonal_mean", - "plot_mesh", - "plot_variable", - "plot_zonal_mean", - ): + for name in ("analyze_dataset", "run_analysis", "plot_dataset"): assert name in tools assert "use_remote=True" in tools[name]["reason"] @@ -215,45 +203,45 @@ def test_face_centered_variables_detected(self, synthetic_mesh_with_data): for var in result["variables"]: assert var["location"] == "faces" - def test_zonal_mean_applicable_with_face_data(self, synthetic_mesh_with_data): - """calculate_zonal_mean is applicable when face-centered data exists.""" + def test_run_analysis_applicable_with_face_data(self, synthetic_mesh_with_data): + """run_analysis covers zonal mean when face-centered data exists.""" grid_file, data_file = synthetic_mesh_with_data result = get_capabilities(grid_file, data_file) tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["calculate_zonal_mean"]["applicable"] is True + assert tools["run_analysis"]["applicable"] is True - def test_inspect_variable_applicable_with_data(self, synthetic_mesh_with_data): - """inspect_variable is applicable when data_path provided.""" + def test_analyze_dataset_applicable_with_data(self, synthetic_mesh_with_data): + """analyze_dataset is applicable when data_path provided.""" grid_file, data_file = synthetic_mesh_with_data result = get_capabilities(grid_file, data_file) tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["inspect_variable"]["applicable"] is True + assert tools["analyze_dataset"]["applicable"] is True - def test_validate_dataset_applicable_with_data(self, synthetic_mesh_with_data): - """validate_dataset is applicable when data_path provided.""" + def test_plot_dataset_applicable_with_data(self, synthetic_mesh_with_data): + """plot_dataset is the public plotting front door.""" grid_file, data_file = synthetic_mesh_with_data result = get_capabilities(grid_file, data_file) tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["validate_dataset"]["applicable"] is True + assert tools["plot_dataset"]["applicable"] is True - def test_subset_compare_and_remap_tools_surface_with_face_data( + def test_subset_compare_and_remap_operations_surface_with_face_data( self, synthetic_mesh_with_data ): - """Face-centered data should unlock the newer analysis tools.""" + """Face-centered data should unlock newer run_analysis operations.""" grid_file, data_file = synthetic_mesh_with_data result = get_capabilities(grid_file, data_file) - tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["subset_bbox"]["applicable"] is True - assert tools["compare_fields"]["applicable"] is True - assert tools["remap_variable"]["applicable"] is True + for var in result["variables"]: + assert "run_analysis:subset_bbox" in var["applicable_mcp_tools"] + assert "run_analysis:compare_fields" in var["applicable_mcp_tools"] + assert "run_analysis:remap_variable" in var["applicable_mcp_tools"] def test_face_var_applicable_tools(self, synthetic_mesh_with_data): - """Face-centered variables list calculate_zonal_mean as applicable.""" + """Face-centered variables list zonal mean as a run_analysis operation.""" grid_file, data_file = synthetic_mesh_with_data result = get_capabilities(grid_file, data_file) for var in result["variables"]: - assert "calculate_zonal_mean" in var["applicable_mcp_tools"] + assert "run_analysis:calculate_zonal_mean" in var["applicable_mcp_tools"] def test_face_var_uxarray_methods_include_zonal_mean( self, synthetic_mesh_with_data @@ -372,11 +360,13 @@ def test_node_data_location_detected(self, mesh_with_node_data): assert var["location"] in ("nodes", "other") def test_zonal_mean_not_applicable_with_only_node_data(self, mesh_with_node_data): - """calculate_zonal_mean is not applicable without face-centered data.""" + """Zonal mean is not suggested without face-centered data.""" grid_file, data_file = mesh_with_node_data result = get_capabilities(grid_file, data_file) - tools = {t["name"]: t for t in result["mcp_server_tools"]} - assert tools["calculate_zonal_mean"]["applicable"] is False + for var in result.get("variables", []): + assert ( + "run_analysis:calculate_zonal_mean" not in var["applicable_mcp_tools"] + ) class TestGetCapabilitiesErrors: diff --git a/tests/test_server.py b/tests/test_server.py index 0d3dcf5..c70a1b4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -14,59 +14,25 @@ async def _registered_tools(): @pytest.mark.asyncio -async def test_inspect_mesh_tool_registered(): - """Verify all expected tools are registered with the MCP server.""" +async def test_public_tool_surface_is_small_and_intent_shaped(): + """Verify the MCP server exposes front doors, not every implementation tool.""" tools = await _registered_tools() expected_tools = { "get_capabilities", - "list_datasets", - "run_scientific_agent", + "analyze_dataset", + "run_analysis", + "plot_dataset", + "diagnose_endpoint", + "probe_path_access", "run_workflow", "resume_workflow", - "get_workflow_status", - "create_session", - "register_dataset", - "get_session_state", - "reset_session_state", - "get_result_handle", - "get_operation_status", - "list_operations", - "inspect_mesh", - "inspect_variable", - "calculate_area", - "calculate_zonal_mean", - "validate_dataset", - "plot_mesh", - "plot_variable", - "plot_zonal_mean", - "subset_bbox", - "subset_polygon", - "extract_cross_section", - "compare_fields", - "calculate_bias", - "calculate_rmse", - "calculate_pattern_correlation", - "remap_variable", - "regrid_dataset", - "calculate_temporal_mean", - "calculate_anomaly", - "calculate_ensemble_mean", - "calculate_ensemble_spread", - "export_to_netcdf", - "export_to_csv", - "write_result", - "calculate_gradient", - "calculate_curl", - "calculate_divergence", - "calculate_azimuthal_mean", - "endpoint_status", - "get_execution_mode", - "probe_path_access", - "set_execution_mode", - "validate_hpc_setup", + "get_status", + "get_result", + "manage_session", } - assert expected_tools.issubset(set(tools.keys())) + assert set(tools.keys()) == expected_tools + assert len(tools) <= 12 @pytest.mark.asyncio @@ -78,10 +44,10 @@ async def test_no_hpc_suffixed_tool_names(): @pytest.mark.asyncio -async def test_dispatched_tools_accept_use_remote(): - """Tools that wrap the dispatcher must expose use_remote/endpoint kwargs.""" +async def test_low_level_implementation_tools_are_not_registered(): + """The MCP surface should not expose low-level implementation verbs.""" tools = await _registered_tools() - dispatched = { + hidden = { "inspect_mesh", "inspect_variable", "calculate_area", @@ -93,8 +59,17 @@ async def test_dispatched_tools_accept_use_remote(): "calculate_curl", "calculate_divergence", "calculate_azimuthal_mean", + "endpoint_status", + "validate_hpc_setup", } - for name in dispatched: + assert hidden.isdisjoint(tools) + + +@pytest.mark.asyncio +async def test_front_door_dispatch_tools_accept_remote_kwargs(): + """Remote execution remains available through the intent-shaped tools.""" + tools = await _registered_tools() + for name in ("analyze_dataset", "run_analysis", "plot_dataset"): tool = tools[name] sig = inspect.signature(tool.fn) assert "use_remote" in sig.parameters, name diff --git a/tests/test_vector_calc.py b/tests/test_vector_calc.py index efcc904..69d0d2a 100644 --- a/tests/test_vector_calc.py +++ b/tests/test_vector_calc.py @@ -329,7 +329,7 @@ def test_accepts_use_remote_endpoint_session_params(self): @pytest.mark.asyncio -async def test_vector_calc_tools_registered(): +async def test_vector_calc_operations_available_through_run_analysis(): from uxarray_mcp.server import mcp if hasattr(mcp, "get_tools"): @@ -338,13 +338,9 @@ async def test_vector_calc_tools_registered(): tools_list = await mcp.list_tools() tools = {t.name: t for t in tools_list} - for name in ( - "calculate_gradient", - "calculate_curl", - "calculate_divergence", - "calculate_azimuthal_mean", - ): - assert name in tools, f"Tool '{name}' not registered" + assert "run_analysis" in tools + for name in ("gradient", "curl", "divergence", "azimuthal_mean"): + assert name in tools["run_analysis"].description @pytest.mark.asyncio