Ocean MCP
by ishimwe5555
README.md
# Ocean MCP
An MCP server that lets an LLM work with [Copernicus Marine](https://data.marine.copernicus.eu/) ocean data through **semantic, ocean-data-shaped tools** — not a 1:1 wrapper around the [Copernicus Marine Toolbox](https://pypi.org/project/copernicusmarine/) Python API.
> "Find daily sea-surface temperature around Marseille for summer 2025 and calculate the monthly averages."
## Why a semantic layer, not a thin wrapper
The Copernicus Marine Toolbox is a general-purpose data-access library: `describe()`, `subset()`, `open_dataset()`, `read_dataframe()`. Exposing those 1:1 as MCP tools would hand the LLM low-level plumbing (raw dataset IDs, service names, coordinate-selection strategies) and force it to reconstruct scientific judgment the tools should already encode — e.g. that a request needs the *nearest grid cell*, that a 2D dataset has no depth axis, or that a full catalogue crawl takes ~7 minutes and can't run inside a single tool call.
Instead, this server sits a domain layer between the LLM and the Toolbox:
```
MCP Layer (mcp_tools/) — thin: parse input, call one service, shape output
|
v
Ocean Service Layer (services/) — all business logic
|
+--> Dataset Discovery (services/catalogue.py)
+--> Metadata (services/metadata.py)
+--> Validation (services/validation.py)
+--> Data Access (services/extraction.py)
|
v
Copernicus Marine Toolbox (copernicus/client.py — the only module that imports it)
```
`mcp_tools/*` never talks to `copernicusmarine` directly and contains no business logic — it validates input via Pydantic, calls one service function, and returns one schema. All science and correctness logic (which service to prefer, whether a variable exists, whether a date falls in range) lives in `services/*`, which is fully testable without network access via a fake `CopernicusClient`.
## What was verified before building this (not assumed)
- **`describe()` without a `product_id`/`dataset_id` scope crawls the entire ~1,260-dataset catalogue and takes several minutes.** `contains=[...]` does *not* speed this up — it still walks the whole catalogue and filters after. This is why `search_ocean_datasets` never calls `describe()` live: it searches a local JSON index built offline by `scripts/refresh_catalogue_cache.py`.
- **Datasets often expose multiple zarr services with identical variables/coordinates but different chunking** — `arco-geo-series` (optimized for spatial slabs) vs. `arco-time-series` (optimized for point/time-series access). `extract_point_timeseries` explicitly prefers `arco-time-series` rather than trusting auto-selection, and reports which service it used.
- **Time coordinate values are not uniformly typed across the catalogue**: gridded/model datasets use epoch milliseconds, but in-situ observation datasets use ISO 8601 strings directly (`coordinate_unit == "ISO8601"`). `time_utils.coerce_time_value_to_iso` handles both.
- **`read_dataframe()` returns time/latitude/longitude as a `MultiIndex`, not columns.** `services/extraction.py` normalizes this before use.
- **`copernicusmarine` reads `COPERNICUSMARINE_SERVICE_USERNAME`/`PASSWORD` into module-level constants at import time**, not lazily per call. This means `.env` must be loaded *before* anything imports `copernicusmarine` — see the ordering in `server.py` and `tests/conftest.py`.
- 2D (surface-only) datasets have **no depth coordinate at all**, which is how `validate_data_request` distinguishes "depth not applicable to this dataset" from "depth out of range". **3D (depth-resolved) datasets**, conversely, silently return every depth level if none is requested — `extract_point_timeseries`/`extract_area_statistics` now require an explicit `depth` whenever `has_depth` is true, rather than averaging across levels no one asked for.
- **A depth coordinate's `minimum_value`/`maximum_value` are sometimes both `None`** even though the axis has real bounds — some ocean model products report depth as a discrete list of levels (`coordinate.values`) instead of a continuous range. `coordinate_utils.coordinate_min_max` falls back to `min(values)`/`max(values)`.
- **`subset()`/`open_dataset()` need `coordinates_selection_method="nearest"` explicitly** (matching what `read_dataframe()` already used) — with the default `"inside"`, a depth/bbox request that doesn't land exactly on a grid point can match nothing and silently return all-`NaN` data instead of an error.
- **`xr.Dataset.resample(time=freq).map(fn)` does not reduce the time dimension for you** — each group passed to `fn` still has its own multi-step "time" axis inside it. `extract_area_statistics` initially reduced only `latitude`/`longitude` per group, so "monthly" aggregation silently produced one row per input day instead of one per month. `services/area_statistics.py` now also reduces over `time` inside each resample group.
- **`ResponseSubset`** (from `subset()`, verified via `dry_run=True`) fields: `file_path`, `output_directory`, `filename`, `file_size`/`data_transfer_size` (MB), `variables`, `coordinates_extent` (a list of `GeographicalExtent`/`TimeExtent` objects with `minimum`/`maximum`/`coordinate_id`), `status`, `message`, `file_status`, `file_names`.
- **`open_dataset()`'s returned `xr.Dataset`** uses plain dims `{time, latitude, longitude}` with no `MultiIndex` surprise (unlike `read_dataframe()`) — but at least one real product decodes its `time` coordinate to a *valid* `datetime64` dtype with semantically wrong values (clustered near the 1970 epoch). A dtype check alone doesn't catch this; `extract_area_statistics` also sanity-checks the decoded range against the requested date range before aggregating.
## Tools
| Tool | Purpose |
|---|---|
| `search_ocean_datasets` | Rank candidate datasets from the local catalogue cache against a free-text query, optional variables, region, and date range. |
| `get_dataset_metadata` | Normalized metadata for one dataset: variables (units, standard names), spatial/temporal coverage, depth availability, provider, and which service to prefer for point vs. area extraction. Prefers a live scoped lookup (seconds), falls back to the cache. |
| `recommend_dataset` | Recommends the single most appropriate dataset for a scientific request — re-verifies coverage against live metadata and prefers gap-filled L4 analysis products over raw L3/L3S swath products, rather than trusting a variable-name match alone. Reports concrete reasoning, limitations, and up to two alternatives with why they weren't picked. |
| `validate_data_request` | Checks a proposed extraction before running it — dataset/variable existence (with closest-match suggestions), coordinate/bbox validity, date coverage, depth applicability — and returns `{valid, errors, warnings, estimated_output_size_mb}` rather than raising, so an agent can self-correct. |
| `extract_point_timeseries` | Validates, then extracts a time series at the nearest grid cell to a point. Reports requested vs. actual coordinates, the distance between them, units, and missing-value counts — nothing is silent. |
| `extract_area_statistics` | Spatial mean/min/max/std/percentile over a bounding box, optionally aggregated over time (daily/monthly/yearly). Mean/std are weighted by `cos(latitude)` to account for grid-cell area shrinking toward the poles; percentile is unweighted, and that limitation is stated in the response, not hidden. |
| `subset_ocean_data` | Semantic wrapper over `subset()`: extracts a bounding box/time/depth range to a local NetCDF or CSV file and returns metadata about it (path, size, coverage) — never the file's contents. |
| `compute_ocean_statistics` | Pure computation (mean/min/max/std/percentile/anomaly/trend/correlation) over values already returned by a prior tool call — no re-fetching, no server-side result store. |
### Known limitation: free-text relevance ranking
`search_ocean_datasets`/`recommend_dataset` score candidates with keyword/phrase matching over title, keywords, and description — no domain ontology. On a real query for "chlorophyll concentration," it recommended a dinoflagellate-biomass model product over the dataset literally named `bgc-chl`, because the two tied on score and the tie-break fell on catalogue order. The tie itself is defensible (both are real, related biogeochemistry variables), but the ranking is naive; a synonym/variable-alias table would be the natural next improvement.
## Setup
Requires Python ≥3.11 and [`uv`](https://docs.astral.sh/uv/).
```bash
uv sync
```
### Credentials
Copy `.env.example` to `.env` and fill in your real [Copernicus Marine](https://data.marine.copernicus.eu/register) credentials.
Credentials are read only via `COPERNICUSMARINE_SERVICE_USERNAME`/`COPERNICUSMARINE_SERVICE_PASSWORD`, by the Toolbox itself. This project never reads, logs, or returns their values — `config.py` only checks they're *present*, and errors surfaced to the LLM (`errors.py` → `fastmcp.exceptions.ToolError`) never include secret values or raw stack traces.
### Build the catalogue cache
`search_ocean_datasets` reads a local index rather than crawling the catalogue live. Build it once (takes several minutes):
```bash
uv run python scripts/refresh_catalogue_cache.py
```
Re-run periodically to refresh (`get_dataset_metadata`'s response reports `catalogue_snapshot_date` when it falls back to the cache, so staleness is always visible).
### Run the server
```bash
uv run python -m ocean_mcp.server
```
## Claude Code / MCP client configuration
Add to your MCP client config (e.g. `.mcp.json`):
```json
{
"mcpServers": {
"ocean-mcp": {
"command": "uv",
"args": ["run", "python", "-m", "ocean_mcp.server"],
"cwd": "/absolute/path/to/mcp-toolbox",
"env": {
"COPERNICUSMARINE_SERVICE_USERNAME": "your-username",
"COPERNICUSMARINE_SERVICE_PASSWORD": "your-password"
}
}
}
}
```
(Credentials can instead be left out of the config and picked up from `.env`/the environment where the server process runs — whichever keeps them furthest from source control in your setup.)
## Example queries
- "Find daily sea surface temperature datasets covering the Mediterranean."
- "Get metadata for cmems_obs-sst_med_phy_my_l3s_P1D-m — what variables and depth range does it have?"
- "Recommend a dataset for daily sea surface temperature around Marseille in summer 2025."
- "Validate a request for thetao at 43.2965, 5.3698 between June and September 2025."
- "Give me daily SST at 43.2965, 5.3698 for the first week of June 2025."
- "Calculate the monthly average sea surface temperature over the Gulf of Lion for Q1 2025."
- "Subset SST over the Gulf of Lion for June 1–3, 2025 to a NetCDF file."
- "What's the trend in those monthly SST values?"
## Development
```bash
uv run pytest tests/unit tests/integration # no network required
uv run ruff check .
uv run mypy src
```
Live tests hit the real Copernicus Marine service and require real credentials plus an explicit opt-in:
```bash
RUN_LIVE_COPERNICUS_TESTS=1 uv run pytest tests/live -m live
```
### Test layout
- `tests/unit/` — services and schemas against a fake `CopernicusClient` (`tests/fakes.py`, built from live-verified model shapes), no network.
- `tests/integration/` — full `mcp_tools` call paths via `fastmcp.Client` in-memory, checking schema shape and error translation (`ToolError`).
- `tests/live/` — real `describe()`/`read_dataframe()`/`open_dataset()`/`subset()` calls, skipped unless opted in.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues