Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added prototypes/__init__.py
Empty file.
100 changes: 100 additions & 0 deletions prototypes/simboard_bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# SimBoard ↔ uxarray-mcp bridge (prototype)

Two thin tools that let an MCP agent chain
**SimBoard (find runs)** → **uxarray-mcp (analyze them)** in one conversation.

This lives under `prototypes/` and is intentionally **not** wired into the
registry (`registry.py`) yet. It's a proof of concept to share with Tom Vo
(SimBoard lead) and Rob Jacob before committing to the integration.

## What the demo looks like

```text
User: "Find the latest completed Chrysalis run and compute its face areas."

Agent → simboard_list_simulations(machine="chrysalis", status="completed", limit=5)
SimBoard returns the matching simulations with execution IDs and metadata.

Agent → simboard_get_paths(sim_id="bae7fd01-...")
SimBoard returns:
{
"execution_id": "10011002.260610-072848",
"machine": "chrysalis",
"grid_name": "ne30pg2_r05_IcoswISC30E3r5",
"artifacts": {
"output": "/lcrc/group/e3sm/ac.golaz/.../run",
"archive": "/lcrc/group/e3sm/ac.golaz/.../archive/...",
}
}

Agent → run_analysis(operation="calculate_area",
grid_path="/lcrc/group/.../grid.nc",
use_remote=True, endpoint="chrysalis")
uxarray-mcp computes on Chrysalis (ANL/LCRC) via Globus Compute.
```

Two providers, one conversation, no glue code on either side.

The SimBoard REST API contract this bridge targets (the `-api` subdomain and
`/api/v1` prefix) is documented upstream in
[E3SM-Project/simboard#246](https://github.com/E3SM-Project/simboard/pull/246).

## Layout

- `client.py` — typed Python client against `simboard-dev-api.e3sm.org`.
Uses `urllib` (stdlib) — no new dep.
- `tools.py` — two MCP-shaped functions (`simboard_list_simulations`,
`simboard_get_paths`) that the server would expose.
- `example.py` — self-contained, **offline** demo of the full
discovery → analysis chain (mocked SimBoard + local HEALPix grid).
No network, no HPC, no data files needed.
- `smoke.py` — end-to-end smoke test against the live dev deployment.

## Running

Offline example (runs anywhere):

```bash
uv run python -m prototypes.simboard_bridge.example
```

Live smoke test (needs network access to the SimBoard dev API):

```bash
uv run python -m prototypes.simboard_bridge.smoke
```

## Endpoints used (all GET, no auth needed today)

| Tool | SimBoard endpoint |
|---|---|
| `simboard_list_simulations` | `GET /api/v1/cases` (and `/api/v1/simulations`) |
| `simboard_get_paths` | `GET /api/v1/simulations/{sim_id}` |

OpenAPI schema: <https://simboard-dev-api.e3sm.org/openapi.json>
Frontend: <https://simboard-dev.e3sm.org>

## Status / next steps

- [x] Confirmed live dev API at `simboard-dev-api.e3sm.org`.
- [x] Verified case + simulation list endpoints return rich metadata.
- [x] Verified simulation-detail returns archive/output/run_script paths
with machine identity (Perlmutter/NERSC).
- [ ] Decide on auth model for production (today the dev API is open;
production will need either OAuth token or service-account creds).
- [ ] Path translation: SimBoard returns NERSC paths; we'd route to the
`nersc`-named Globus Compute endpoint via uxarray-mcp's existing
`path_prefix` mechanism. No new code, just config.
- [ ] If we move past prototype: graduate `tools.py` to
`src/uxarray_mcp/tools/simboard.py`, add unit tests, decide whether
to expose as front-door tools or under `analyze_dataset` enrichment.

## Why this stays out of the main server (for now)

Until Tom blesses the integration, this is speculative work against a dev
deployment. Putting it in `src/` would advertise it as supported. The
prototype is enough to:

1. Demo the workflow to Rob/Tom.
2. Validate the API surface is what we need.
3. Quantify the work it would take to ship.
Empty file.
84 changes: 84 additions & 0 deletions prototypes/simboard_bridge/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Minimal HTTP client for the SimBoard REST API.

Stdlib only — no new dependency. Reads JSON from
https://simboard-dev-api.e3sm.org/api/v1/ by default; override via the
SIMBOARD_API_BASE env var when the production deployment lands.

This is a *prototype* client. It does not handle pagination cursors,
retries, auth tokens, or anything else a production client would need.
"""

from __future__ import annotations

import json
import os
import ssl
import urllib.error
import urllib.parse
import urllib.request
from typing import Any

DEFAULT_BASE = "https://simboard-dev-api.e3sm.org/api/v1"


class SimBoardClient:
"""Thin wrapper around the SimBoard REST API."""

def __init__(
self,
base_url: str | None = None,
token: str | None = None,
timeout_s: float = 15.0,
) -> None:
self.base_url = (
base_url or os.environ.get("SIMBOARD_API_BASE", DEFAULT_BASE)
).rstrip("/")
self.token = token or os.environ.get("SIMBOARD_API_TOKEN")
self.timeout_s = timeout_s
self._ctx = ssl.create_default_context()

# ---- low-level ----

def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
url = f"{self.base_url}{path}"
if params:
clean = {k: v for k, v in params.items() if v is not None}
if clean:
url = f"{url}?{urllib.parse.urlencode(clean, doseq=True)}"
headers = {"User-Agent": "uxarray-mcp-simboard-bridge/0.1"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(
req, context=self._ctx, timeout=self.timeout_s
) as r:
return json.load(r)
except urllib.error.HTTPError as e:
body = e.read(500).decode(errors="ignore") if e.fp else ""
raise RuntimeError(
f"SimBoard GET {url} failed: HTTP {e.code} — {body[:200]}"
) from e
except urllib.error.URLError as e:
raise RuntimeError(f"SimBoard GET {url} unreachable: {e.reason}") from e

# ---- typed endpoints ----

def health(self) -> dict[str, Any]:
return self._get("/health")

def list_cases(self) -> list[dict[str, Any]]:
return self._get("/cases")

def list_simulations(
self,
case_name: str | None = None,
case_group: str | None = None,
) -> list[dict[str, Any]]:
return self._get(
"/simulations",
params={"case_name": case_name, "case_group": case_group},
)

def get_simulation(self, sim_id: str) -> dict[str, Any]:
return self._get(f"/simulations/{sim_id}")
200 changes: 200 additions & 0 deletions prototypes/simboard_bridge/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Offline, runnable example: SimBoard discovery → uxarray-mcp analysis.

This showcases the full "find a run, then analyze it" workflow that the
SimBoard bridge enables, using the SimBoard REST API contract documented in
E3SM-Project/simboard#246 (the ``-api`` subdomain + ``/api/v1`` prefix).

Unlike ``smoke.py`` (which hits the live dev deployment and would hand off to
an HPC endpoint), this example is **fully self-contained**:

* SimBoard responses are mocked with a tiny in-process fake client, so no
network access is required and the documented JSON shapes are visible inline.
* The final analysis runs **locally** on a synthetic HEALPix grid
(``healpix:2``), so no data files and no Globus Compute endpoint are needed.

The point is to demonstrate the conversational chain and the exact handoff
into ``uxarray-mcp.run_analysis`` without any external dependencies.

Run it::

uv run python -m prototypes.simboard_bridge.example
"""

from __future__ import annotations

import json
from typing import Any

from prototypes.simboard_bridge.tools import (
simboard_get_paths,
simboard_list_simulations,
)

# ---------------------------------------------------------------------------
# A minimal fake SimBoard client.
#
# It returns the same JSON shapes the real REST API returns (see the OpenAPI
# schema at https://simboard-dev-api.e3sm.org/openapi.json). Only the two
# endpoints the bridge uses are implemented: ``/cases`` and
# ``/simulations/{id}``.
# ---------------------------------------------------------------------------

_FAKE_CASES: list[dict[str, Any]] = [
{
"name": "v3.LR.piControl",
"machineNames": ["chrysalis"],
"hpcUsernames": ["golaz"],
"simulations": [
{
"id": "bae7fd01-0000-4000-8000-000000000001",
"executionId": "10011002.260610-072848",
"status": "completed",
"simulationStartDate": "0001-01-01",
"simulationEndDate": "0050-12-31",
}
],
},
{
"name": "v3.HR.historical",
"machineNames": ["perlmutter"],
"hpcUsernames": ["whannah"],
"simulations": [
{
"id": "bae7fd01-0000-4000-8000-000000000002",
"executionId": "54258454.260101-000000",
"status": "running",
"simulationStartDate": "1850-01-01",
"simulationEndDate": "2014-12-31",
}
],
},
]

_FAKE_SIM_DETAILS: dict[str, dict[str, Any]] = {
"bae7fd01-0000-4000-8000-000000000001": {
"executionId": "10011002.260610-072848",
"caseName": "v3.LR.piControl",
"gridName": "ne30pg2_r05_IcoswISC30E3r5",
"gridResolution": "ne30",
"compset": "WCYCL1850",
"machine": {
"name": "chrysalis",
"site": "ANL-LCRC",
"scheduler": "slurm",
},
"groupedArtifacts": {
"output": [
{"uri": "/lcrc/group/e3sm/ac.golaz/v3.LR.piControl/run"},
],
"archive": [
{"uri": "/lcrc/group/e3sm/ac.golaz/v3.LR.piControl/archive/atm"},
],
"run_script": [
{"uri": "/home/ac.golaz/E3SM/v3.LR.piControl.run"},
],
},
"links": [
{"label": "diagnostics", "url": "https://web.lcrc.anl.gov/.../e3sm_diags"},
],
}
}


class FakeSimBoardClient:
"""In-process stand-in for ``SimBoardClient`` — returns canned JSON."""

base_url = "https://simboard-dev-api.e3sm.org/api/v1 (mocked)"

def list_cases(self) -> list[dict[str, Any]]:
return _FAKE_CASES

def get_simulation(self, sim_id: str) -> dict[str, Any]:
return _FAKE_SIM_DETAILS[sim_id]


# ---------------------------------------------------------------------------
# The workflow.
# ---------------------------------------------------------------------------


def main() -> int:
client = FakeSimBoardClient()

print("=" * 70)
print("SimBoard -> uxarray-mcp (offline example)")
print("=" * 70)
print(f"SimBoard REST API: {client.base_url}\n")
print(
'User: "Find the latest completed Chrysalis run and check its\n'
' mesh + face areas."\n'
)

# ---- Step 1: discover a simulation -----------------------------------
print("[1] simboard_list_simulations(machine='chrysalis', status='completed')")
listing = simboard_list_simulations(
machine="chrysalis",
status="completed",
limit=5,
client=client, # type: ignore[arg-type]
)
print(f" -> {listing['count']} match(es)")
for sim in listing["simulations"]:
print(
f" {sim['execution_id']} "
f"case={sim['case_name']} status={sim['status']}"
)

pick = listing["simulations"][0]
print(f"\n Agent picks: sim_id={pick['sim_id']}\n")

# ---- Step 2: resolve paths + machine ---------------------------------
print(f"[2] simboard_get_paths(sim_id='{pick['sim_id']}')")
paths = simboard_get_paths(pick["sim_id"], client=client) # type: ignore[arg-type]
print(
" -> grid={grid_name!r} compset={compset!r}\n"
" machine={machine_hint!r} site={machine_site!r} "
"scheduler={machine_scheduler!r}".format(**paths)
)
for kind, uris in (paths.get("artifacts") or {}).items():
for uri in uris:
print(f" [{kind}] {uri}")

machine = paths["machine_hint"]
print(
f"\n In production the agent maps machine {machine!r} -> the "
"Globus Compute\n endpoint registered as 'chrysalis' and calls "
"run_analysis(use_remote=True).\n Path-prefix routing handles "
"/lcrc/group/... automatically.\n"
)

# ---- Step 3: run the analysis ----------------------------------------
# For this offline demo we analyze a synthetic HEALPix grid locally
# instead of the (unreachable) Chrysalis path, so the example runs anywhere.
from uxarray_mcp.tools.frontdoor import run_analysis

demo_grid = "healpix:2"
print(f"[3] run_analysis(operation='inspect_mesh', grid_path={demo_grid!r})")
print(" (offline demo grid; production would use the resolved Chrysalis path)")
mesh = run_analysis(operation="inspect_mesh", grid_path=demo_grid)
print(
f" -> format={mesh['format']} n_face={mesh['n_face']} "
f"n_node={mesh['n_node']}"
)

print(f"\n[4] run_analysis(operation='calculate_area', grid_path={demo_grid!r})")
area = run_analysis(operation="calculate_area", grid_path=demo_grid)
print(
f" -> total_area={area['total_area']:.4e} {area['area_units']} "
f"mean_area={area['mean_area']:.4e}"
)

print("\n" + "=" * 70)
print("Done. Two providers (SimBoard + uxarray-mcp), one conversation.")
print("=" * 70)
print("\nProvenance from the final area result:")
print(json.dumps(area.get("_provenance", {}), indent=2, default=str)[:800])
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading