Ocean MCP
Enables LLMs to work with Copernicus Marine ocean data through semantic tools for dataset discovery, metadata retrieval, recommendation, validation, point time-series extraction, area statistics, subsetting, and computation without exposing raw API plumbing.
README
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.
What was verified before building this (not assumed)
describe()without aproduct_id/dataset_idscope 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 whysearch_ocean_datasetsnever callsdescribe()live: it searches a local JSON index built offline byscripts/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_timeseriesexplicitly prefersarco-time-seriesrather 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_isohandles both. read_dataframe()returns time/latitude/longitude as aMultiIndex, not columns.services/extraction.pynormalizes this before use.copernicusmarinereadsCOPERNICUSMARINE_SERVICE_USERNAME/PASSWORDinto module-level constants at import time, not lazily per call. This means.envmust be loaded before anything importscopernicusmarine— see the ordering inserver.pyandtests/conftest.py.- 2D (surface-only) datasets have no depth coordinate at all, which is how
validate_data_requestdistinguishes "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_statisticsnow require an explicitdepthwheneverhas_depthis true, rather than averaging across levels no one asked for. - A depth coordinate's
minimum_value/maximum_valueare sometimes bothNoneeven 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_maxfalls back tomin(values)/max(values). subset()/open_dataset()needcoordinates_selection_method="nearest"explicitly (matching whatread_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-NaNdata instead of an error.xr.Dataset.resample(time=freq).map(fn)does not reduce the time dimension for you — each group passed tofnstill has its own multi-step "time" axis inside it.extract_area_statisticsinitially reduced onlylatitude/longitudeper group, so "monthly" aggregation silently produced one row per input day instead of one per month.services/area_statistics.pynow also reduces overtimeinside each resample group.ResponseSubset(fromsubset(), verified viadry_run=True) fields:file_path,output_directory,filename,file_size/data_transfer_size(MB),variables,coordinates_extent(a list ofGeographicalExtent/TimeExtentobjects withminimum/maximum/coordinate_id),status,message,file_status,file_names.open_dataset()'s returnedxr.Datasetuses plain dims{time, latitude, longitude}with noMultiIndexsurprise (unlikeread_dataframe()) — but at least one real product decodes itstimecoordinate to a validdatetime64dtype with semantically wrong values (clustered near the 1970 epoch). A dtype check alone doesn't catch this;extract_area_statisticsalso 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 fakeCopernicusClient(tests/fakes.py, built from live-verified model shapes), no network.tests/integration/— fullmcp_toolscall paths viafastmcp.Clientin-memory, checking schema shape and error translation (ToolError).tests/live/— realdescribe()/read_dataframe()/open_dataset()/subset()calls, skipped unless opted in.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.
E2B
Using MCP to run code via e2b.