Skip to main content
Glama

Ocean MCP

An MCP server that lets an LLM work with Copernicus Marine ocean data through semantic, ocean-data-shaped tools — not a 1:1 wrapper around the Copernicus Marine Toolbox 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.

Related MCP server: erddap-mcp-demo

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.

uv sync

Credentials

Copy .env.example to .env and fill in your real Copernicus Marine 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):

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

uv run python -m ocean_mcp.server

Claude Code / MCP client configuration

Add to your MCP client config (e.g. .mcp.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

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:

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Access oceanographic and environmental data from 63+ ERDDAP servers worldwide through natural language queries. Search datasets, retrieve metadata, and download scientific data for climate research, marine biology, and coastal management.
    18
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI assistants to search, query, and manage ocean/environmental datasets from the Oceanum platform, and to read, write, and delete files in Oceanum cloud storage.
    1
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Enables natural language querying of oceanographic data from the NES-LTER API, including CTD profiles, cruise datasets, and station information.
    10
    MIT