Skip to main content
Glama
oceanum-io

Oceanum MCP

Official
by oceanum-io
README.md
# Oceanum MCP

An [MCP](https://modelcontextprotocol.io/) server package that provides AI assistants with access to the [Oceanum](https://oceanum.io) platform for ocean/environmental data and cloud storage.

## Servers

This package contains multiple MCP servers, selectable at runtime:

| Server     | Description                                                   |
| ---------- | ------------------------------------------------------------- |
| `datamesh` | Search, query, and manage ocean/environmental datasets        |
| `storage`  | List, read, write, and delete files in Oceanum cloud storage  |
| `combined` | All tools from both servers under a single endpoint (default) |

## Prerequisites

Get an API token from [oceanum.io](https://oceanum.io). Set it as the `DATAMESH_TOKEN` environment variable
(or keep it out of the client config: see [Supplying the token without
putting it in the client config](#supplying-the-token-without-putting-it-in-the-client-config)).

## Installation

Requires Python 3.10–3.13. Python 3.14 is not supported yet: the `zarr` 2.x
stack that `oceanum` depends on needs `numcodecs<0.16`, which has no Python
3.14 wheels.

```bash
pip install oceanum-mcp
```

Or run directly with `uvx`. Pass `--python 3.13` so uv doesn't pick Python
3.14 (uv downloads 3.13 if it isn't installed):

```bash
uvx --python 3.13 oceanum-mcp              # combined server (default)
uvx --python 3.13 oceanum-mcp datamesh     # datamesh only
uvx --python 3.13 oceanum-mcp storage      # storage only
uvx --python 3.13 oceanum-mcp --list       # show available servers
```

## Configuration

### Claude Desktop

Add to your `claude_desktop_config.json`:

**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

**Combined server (all tools):**

```json
{
  "mcpServers": {
    "oceanum": {
      "command": "uvx",
      "args": ["--python", "3.13", "oceanum-mcp"],
      "env": {
        "DATAMESH_TOKEN": "your-token-here"
      }
    }
  }
}
```

**Individual server (datamesh only):**

```json
{
  "mcpServers": {
    "oceanum-datamesh": {
      "command": "uvx",
      "args": ["--python", "3.13", "oceanum-mcp", "datamesh"],
      "env": {
        "DATAMESH_TOKEN": "your-token-here"
      }
    }
  }
}
```

### Claude Code

```bash
# Combined server
claude mcp add --transport stdio oceanum -- uvx --python 3.13 oceanum-mcp

# Individual server
claude mcp add --transport stdio oceanum-datamesh -- uvx --python 3.13 oceanum-mcp datamesh
```

Set the token in your environment:

```bash
export DATAMESH_TOKEN=your-token-here
```

### VS Code / Cline / Continue

Use stdio transport with the same command:

```json
{
  "command": "uvx",
  "args": ["--python", "3.13", "oceanum-mcp"],
  "env": {
    "DATAMESH_TOKEN": "your-token-here"
  }
}
```

If the server reports that the token "looks like an unresolved client
variable", your client passed a config variable such as
`${input:datamesh-token}` through literally instead of substituting it.
Restart the server from the client so it prompts for the input again, or use
one of the options below.

### Supplying the token without putting it in the client config

For the stdio transport the server takes its token from the first of these
that is set:

1. `DATAMESH_TOKEN` — the token itself.
2. `DATAMESH_TOKEN_FILE` — path to a file containing the token (`~` is
   expanded). On macOS/Linux the file must be accessible only by you; the
   server refuses it otherwise:

   ```bash
   install -m 600 /dev/null ~/.config/oceanum/token
   echo "your-token-here" > ~/.config/oceanum/token
   ```

   ```json
   "env": { "DATAMESH_TOKEN_FILE": "~/.config/oceanum/token" }
   ```

   The file is read on every call, so a replaced token takes effect without
   restarting the server.
3. `DATAMESH_TOKEN_KEYRING=1` — read the token from the OS credential store
   (macOS Keychain, Secret Service/libsecret on Linux, Windows Credential
   Manager) via the [`keyring`](https://pypi.org/project/keyring/) package,
   under service `oceanum-mcp`, user `datamesh-token`. Store it once with:

   ```bash
   uvx keyring set oceanum-mcp datamesh-token
   ```

   The token is looked up once per server process, so restart the server
   after changing it. `keyring` currently arrives as an indirect dependency
   of `fastmcp`, which is not guaranteed to last: to be sure, install the
   `oceanum-mcp[keyring]` extra (with uvx:
   `uvx --python 3.13 --from "oceanum-mcp[keyring]" oceanum-mcp`).

### Troubleshooting: `-32022` on the first `initialize`

A client may log "connection is serving the 2026-07-28 protocol; the
initialize handshake is not accepted" (error `-32022`) once on connect. This
comes from the MCP Python SDK, not from this server's code: in SDK 2.3.0
(`mcp/server/runner.py`, `serve_dual_era_loop`) a connection's first request
fixes its protocol era. A client that opens with a 2026-07-28 request and
then sends the older `initialize` handshake on the same connection is
refused, with the supported versions in the error. Clients are expected to
retry with a supported version; GitHub Copilot did so and connected.

## Environment Variables

| Variable                      | Required | Description                                                                     |
| ----------------------------- | -------- | ------------------------------------------------------------------------------- |
| `DATAMESH_TOKEN`              | Yes*     | Oceanum API token (shared by all servers)                                       |
| `DATAMESH_TOKEN_FILE`         | Yes*     | Path to a file holding the token, readable only by its owner; used when `DATAMESH_TOKEN` is unset |
| `DATAMESH_TOKEN_KEYRING`      | Yes*     | `1`/`true`/`yes`/`on` to read the token from the OS credential store (once per process); used when neither of the above is set |
| `DATAMESH_SERVICE`            | No       | Custom datamesh service URL (default: `https://datamesh.oceanum.io`)            |
| `STORAGE_SERVICE`             | No       | Custom storage service URL (default: `https://storage.oceanum.io`)              |
| `OCEANUM_DOMAIN`              | No       | Override the base domain for all services (default: `oceanum.io`)               |
| `OCEANUM_MCP_READ_ONLY`       | No       | `1`/`true`/`yes`/`on` to disable write tools (`update_metadata`, storage `write_file`/`delete_file`); any value other than these or `0`/`false`/`no`/`off` stops the server at startup |
| `OCEANUM_MCP_MAX_INLINE_BYTES`| No       | Max staged result size returned inline by `query_data` (default 50,000,000)     |
| `OCEANUM_MCP_MAX_INLINE_ROWS` | No       | Max rows/records previewed inline before truncation (default 100)               |
| `OCEANUM_MCP_EXPORT_DIR`      | No       | If set, `export_query` may only write inside this directory                     |
| `OCEANUM_MCP_STAGE_TIMEOUT`   | No       | Seconds a Datamesh staging/query request may wait on the gateway before the tool returns a timeout error (default 120) |
| `OCEANUM_MCP_AUTH`            | No       | Auth scheme for `--transport http`: `auto` (default), `datamesh`, `auth0`, or `none` |
| `OCEANUM_MCP_AUTH0_DOMAIN`    | No       | Auth0 tenant domain for `auth0` mode (default: `auth.oceanum.io`)               |
| `OCEANUM_MCP_AUTH0_AUDIENCE`  | No       | Auth0 API audience for `auth0` mode (default: `https://api.oceanum.io`)         |
| `OCEANUM_MCP_PUBLIC_URL`      | No       | Externally visible base URL (e.g. `https://mcp.oceanum.io`); enables OAuth discovery metadata for claude.ai connectors |
| `OCEANUM_MCP_CORS_ORIGINS`    | No       | Comma-separated browser origins allowed by CORS on http (default: `https://*.oceanum.io,https://*.oceanum.tech,https://vscode.dev`; empty string disables) |

\* One token source (`DATAMESH_TOKEN`, `DATAMESH_TOKEN_FILE` or
`DATAMESH_TOKEN_KEYRING`) is required for the stdio transport (and for
`--transport http` with `OCEANUM_MCP_AUTH=none`); in authenticated http mode
each request carries its own credential and no server-side token is needed.

## Hosted mode (HTTP transport)

Run any server as a shared, multi-tenant HTTP service:

```bash
oceanum-mcp datamesh --transport http --host 0.0.0.0 --port 8000
```

This serves the MCP streamable-HTTP endpoint at `http://<host>:<port>/<server>`
(`/datamesh` here; override with `--path`). Each server owning its own path
lets several MCP servers share one domain behind an ingress — e.g.
`https://mcp.oceanum.io/datamesh` and `https://mcp.oceanum.io/storage`.
Every request must present a bearer credential, and all Datamesh/Storage calls
are made **as that request's user** — connections are cached per credential and
never shared between tokens.

Auth schemes (`OCEANUM_MCP_AUTH`):

- `auto` (default) — accepts **either** credential, detected per request:
  Datamesh tokens arrive in the `X-DATAMESH-TOKEN` header (the same header
  the gateway itself uses), Auth0 JWTs in `Authorization: Bearer`. A Datamesh
  token sent as the bearer also works (JWT-shape detection routes it), but
  the dedicated header is the canonical form. Each request runs as the
  identity its own credential resolves to.
- `datamesh` — only Datamesh tokens are accepted. The server validates them
  against the gateway. Add to Claude Code with:

  ```bash
  claude mcp add --transport http oceanum-datamesh https://mcp.oceanum.io/datamesh \
    --header "X-DATAMESH-TOKEN: <datamesh-token>"
  ```

- `auth0` — clients send an Auth0-issued JWT, validated against the tenant
  JWKS and forwarded to the Datamesh gateway as-is.
- `none` — no authentication; every request uses the server's own
  `DATAMESH_TOKEN`. Only for trusted-network deployments.

Notes:

- `export_query` works over HTTP but changes shape: instead of writing to the
  server's local filesystem (meaningless for remote clients), it returns a
  time-limited, self-authenticating gateway `download_url` that the caller
  fetches out-of-band. The `path` argument is ignored on hosted servers. The
  URL needs no credential, so it is a capability — treat it as a secret.
  Gateways with scoped download links (OCE-317) also limit its lifetime and
  number of fetches; the response then reports them as `expires_at` and
  `max_uses`.
  Hosted exports are capped at 10 GB to bound what one link can pull, and
  exports above 1 GB need `confirm_size_bytes` (see `export_query`).
- A rejected Datamesh token (in `X-DATAMESH-TOKEN`, or a non-JWT
  `Authorization: Bearer`) gets a 401 saying the token was not accepted,
  without the OAuth `resource_metadata` challenge (which would send clients
  such as VS Code into a sign-in flow that cannot fix a Datamesh token).
  Requests with no credential, and failed JWT (OAuth) bearers, still get the
  challenge, so OAuth discovery and re-authorization are unaffected. If the
  token could not be verified because the Datamesh service was unreachable,
  the response is a 503 asking to retry instead. A credential that is an
  unsubstituted client variable (`${input:...}`) gets a 401 saying so
  (except with `OCEANUM_MCP_AUTH=none`, where client credentials are
  ignored).
- Combine with `OCEANUM_MCP_READ_ONLY=1` to run a read-only public service.
- Pass `--stateless` when running behind a load balancer or on autoscaled
  platforms (Cloud Run, etc.): sessions are otherwise held in instance
  memory, and consecutive requests routed to different instances would fail.
- **Breaking change for `--transport sse`** (deprecated): sse is a network
  transport and now behaves like http — authenticated by default and no
  `export_query`. Set `OCEANUM_MCP_AUTH=none` to restore the old
  unauthenticated behavior.

### Serving under an external ASGI server

To run under uvicorn/gunicorn (multiple workers, serverless platforms), use
the packaged app factory — it applies the same auth and tool policy as the
CLI; serving `mcp.http_app()` directly would bypass both:

```bash
uvicorn --factory oceanum_mcp.app:create_http_app --host 0.0.0.0 --port 8000
```

Requests on a network transport never fall back to the server's
`DATAMESH_TOKEN`: an unauthenticated request fails unless
`OCEANUM_MCP_AUTH=none` was set explicitly.

### Browser clients (CORS)

Browser-based MCP clients send a CORS preflight (`OPTIONS`) before every
cross-origin request. The http app answers preflights for an allowlist of
origins, ahead of authentication (a preflight never carries a credential);
all other requests still require one. Configure the allowlist with
`OCEANUM_MCP_CORS_ORIGINS`, comma-separated:

```bash
OCEANUM_MCP_CORS_ORIGINS="https://*.oceanum.io,https://vscode.dev,http://localhost:6274"
```

- Unset: `https://*.oceanum.io`, `https://*.oceanum.tech` and
  `https://vscode.dev`.
- Empty string: CORS disabled — no CORS headers, preflights are not answered.
- Each entry is an exact origin (`scheme://host[:port]`, no path). A leading
  `*.` matches exactly one subdomain label: `https://*.oceanum.io` allows
  `https://app.oceanum.io` but not `https://oceanum.io`,
  `https://a.b.oceanum.io` or `https://oceanum.io.evil.com`. Bare `*`,
  single-label wildcards (`https://*.io`) and malformed entries are rejected
  at startup; a scheme-default port (`:443` on https) is dropped, as browsers
  omit it. Multi-label public suffixes (`https://*.co.uk`,
  `https://*.github.io`) cannot be detected — never configure them: they
  would admit every site under that suffix.
- Credentials travel in `Authorization` / `X-DATAMESH-TOKEN` headers, never
  cookies, so `Access-Control-Allow-Credentials` is never sent.
  `Mcp-Session-Id` and `WWW-Authenticate` are exposed to browser scripts.

### claude.ai custom connectors

Set `OCEANUM_MCP_PUBLIC_URL` to the server's public base URL and the `auto`
and `auth0` modes additionally serve OAuth 2.0 Protected Resource Metadata
(RFC 9728) naming the Auth0 tenant, plus a `WWW-Authenticate` challenge on
401s — which is how claude.ai discovers where to send users to authorize.
The Auth0 tenant must allow client registration for Claude (Dynamic Client
Registration, or a pre-registered application) and issue JWT access tokens
for the configured audience. Header/bearer clients are unaffected either way.

### Post-deploy smoke test

`scripts/smoke_hosted.py` opens real gridded datasources over zarr through a
deployed server (`load_datasource`, and a whole-grid `query_data`), which
catches a broken data stack in the image that `tools/list` does not. There is
no deploy pipeline (the hosted service is deployed by hand with the local
`deploy_cloudrun*.sh` scripts), so run it manually after each deploy:

```bash
DATAMESH_TOKEN=<token> uv run python scripts/smoke_hosted.py https://mcp.oceanum.io/datamesh
```

The token must be valid for the gateway the server uses. `--load-datasource`,
`--query-datasource` and `--query-variable` override the default datasets.
The URL must be https unless the host is local, since the token is sent with
every request. Exit status: `0` all checks passed; `1` a check failed, or the
server was unreachable or rejected the token; `2` usage error
(`DATAMESH_TOKEN` unset, or a non-https remote URL); `3` inconclusive (a
datasource was not found, not shared or empty, the query was refused, or it
was small enough to be downloaded instead of opened over zarr).

The hosted image installs this package from the source checkout
(`pip install .`), not from PyPI, so dependency fixes take effect on the next
deploy from `main` without a release.

## Datamesh Tools

The intended workflow is: `search_catalog` → `get_datasource_info` → `stage_query`
(dry run: learn the result size without downloading) → `query_data` for small
results inline, or `export_query` to write large results to a file that analysis
code reads directly.

### `search_catalog`

Search the Datamesh catalog with optional text search, time range, and bounding box filters.
Returns a JSON object with `count` and `results`; if `count` equals `limit`, more matches may exist.
Only datasources accessible to the request's credentials are searched. An empty or short
(below `limit`, with nothing `omitted`) result carries a fixed `scope` note saying a missing datasource either does
not exist or is not accessible with these credentials; it never reveals whether, how many,
or which inaccessible datasources exist.

By default each result is a compact summary: `id`, `name`, a `description`
cut to 200 characters, `tstart`/`tend`, `bounds` (rounded outward to 4
decimals), variable names when the catalog provides them (first 20, with
`variables_total`), and the first 5 `tags` (each clipped to 60 characters,
with `tags_total`). Unusable bounds (empty or non-finite) are `null`.
`detail="full"` returns each datasource's complete catalog record instead.
The response is compact JSON, and its total size is bounded (~20k characters
for summaries, ~100k for full records): matches beyond the bound are dropped,
counted in `omitted`, and a note asks to refine the search.

The catalog ranks datasources by fuzzy similarity to the search text and
returns the closest ones even when none contains it. With search text, the
response therefore carries `text_matches`: how many results have every
search word (as a whole word, singular or plural) in their id, name, description, tags, labels
or variable names. `or` separates alternatives and negated words or phrases (`-era5`, `-"era5 grid"`,
`not era5`) are ignored. This count is only a signal: no result is dropped
for failing it, but results that fail are marked `"text_match": false`, and
a `text_note` ahead of the results explains. If no result contains the text
as whole words (`text_matches: 0`), every result is still returned, but in
summary mode as `id` and `name` only, with a `text_note` first and no
`scope` note.

| Parameter    | Type        | Description                                      |
| ------------ | ----------- | ------------------------------------------------ |
| `search`     | string      | Text search for name, description, or tags       |
| `time_start` | string      | ISO 8601 start time                              |
| `time_end`   | string      | ISO 8601 end time                                |
| `bbox`       | list[float] | Bounding box `[xmin, ymin, xmax, ymax]` in WGS84 |
| `limit`      | int         | Max results to return (default 20)               |
| `detail`     | string      | `summary` (default) or `full`                    |

### `get_datasource_info`

Get the metadata for a datasource: coverage, schema dims and coordinates,
variables, and attributes.

The default (`detail="summary"`) is a compact view with the same fields as
the full record. Each variable and coordinate keeps its `dims`, `shape`,
`dtype` and `units`/`long_name`/`standard_name` attributes only (other
attributes are counted in `attrs_omitted`); the description is clipped to
1,000 characters, global `attributes` and `info` are capped at 25 entries
(with `*_omitted` counts), and long values are clipped to 200 characters.
`schema.data_vars`/`schema.attrs` are omitted as duplicates of
`variables`/`attributes`. Past ~30k characters the summary sheds, in order
and only as far as needed: global attributes, coordinate attributes,
variable attributes other than `units`, variable `shape`/`dtype`, and finally
per-variable details; the `note` names what was shed. Every variable name is
always listed. `detail="full"` returns the complete record. Both are compact
JSON.

An unknown id adds `suggestions`: visible datasources whose id or name
differs from the requested one by a typo (at most one character edit per 7 characters, up
to 3, with the differing words still similar), or extends a request of two
or more words. An id that merely shares generic words such as
`oceanum_wave_..._era5`, or a single word such as `wave`, is never
suggested.

| Parameter       | Type   | Description                   |
| --------------- | ------ | ----------------------------- |
| `datasource_id` | string | Datasource ID                 |
| `detail`        | string | `summary` (default) or `full` |

### `stage_query`

Dry-run a query on the Datamesh gateway: reports the result size, container
type, and domain length **without downloading any data**, echoes the canonical
query, and recommends the next step (inline query vs export vs narrowing).
The inline recommendation counts records as well as bytes: a result with more
records than `OCEANUM_MCP_MAX_INLINE_ROWS` would come back from `query_data` as
a preview, so it is steered to resampling, aggregation or `export_query`
instead. Accepts the same query parameters as `query_data`.

### `query_data`

Query a datasource with filters and return small results inline as
coordinate-attributed JSON records with explicit `truncated`/`lazy` flags.
The query is staged first: gridded results above the inline limit are
summarized lazily (structure only); tabular results above the limit, and any
result above 1 GB, are refused with the staged size and alternatives (the
1 GB refusal also lists each variable's dims). The exception is `limit`
combined with `time_resolution` or `aggregate_operations`: the limited slice
may be small, so it is still opened lazily. Library warnings (e.g. the
2,000,000-row cap on tabular queries) are included in the response.

| Parameter              | Type         | Description                                                        |
| ---------------------- | ------------ | ------------------------------------------------------------------ |
| `datasource_id`        | string       | Datasource to query                                                |
| `variables`            | list[string] | Variables to select                                                |
| `time_start`           | string       | ISO 8601 start of a time range (open-ended if omitted)             |
| `time_end`             | string       | ISO 8601 end of a time range (open-ended if omitted)               |
| `times`                | list[string] | Discrete times (series selection); excludes `time_start`/`time_end`|
| `time_resolution`      | string       | Server-side temporal downsampling (pandas frequency, e.g. `1h`, `1D`, `30D`; not `MS`/`ME`/`QS`/`YS` or finer than 1 minute) |
| `time_resample`        | string       | Resampling method for `time_resolution`: mean, nearest, linear     |
| `bbox`                 | list[float]  | Bounding box `[xmin, ymin, xmax, ymax]`                            |
| `geofilter_feature`    | object       | GeoJSON Feature (Point, MultiPoint, or Polygon) for selection      |
| `geofilter_interp`     | string       | Interpolation for feature selection: nearest or linear             |
| `geofilter_resolution` | float        | Max spatial resolution for downsampling, in CRS units              |
| `level_min`            | float        | Minimum vertical level                                             |
| `level_max`            | float        | Maximum vertical level                                             |
| `levels`               | list[float]  | Discrete vertical levels (series selection)                        |
| `level_interp`         | string       | Interpolation for level series: nearest or linear                  |
| `coord_filters`        | list[object] | Coordinate selections: `[{"coord": "name", "values": [...]}]`      |
| `crs`                  | string/int   | CRS for filter coordinates and returned data                       |
| `aggregate_operations` | list[string] | Aggregation ops: mean, min, max, std, sum                          |
| `aggregate_spatial`    | bool         | Aggregate over spatial dims (default true)                         |
| `aggregate_temporal`   | bool         | Aggregate over temporal dims (default true)                        |
| `limit`                | int          | Keep the last N records (see below)                                |

`limit` keeps the last N records (Datamesh semantics: the last N steps along
time/ensemble), with the same meaning in `stage_query`, `query_data`, and
`export_query`. Combined with `time_resolution` or `aggregate_operations` it is
applied by this server **after** resampling/aggregation instead of being sent
to Datamesh (which would apply it to the native records first), so
`stage_query` reports the unlimited size as an upper bound. Hosted
`export_query` cannot apply it to a gateway download and refuses that
combination: drop `limit` or narrow the time range.

### `export_query`

Export the **full** result of a query — the data-handle path for results too
large to return inline. Behaviour depends on transport:

- **Local (stdio):** writes the result to the local file `path` (required) and
  returns that path. Gridded datasets stream lazily to NetCDF; tabular results
  write Parquet or CSV.
- **Hosted (http/sse):** returns a time-limited, self-authenticating gateway
  `download_url` (choose `format`; `path` is ignored). Fetch it out-of-band.
  When the response includes `expires_at` or `max_uses` (gateways with
  scoped links), fetch it before `expires_at` and at most `max_uses` times.
  Results above 10 GB (for CSV, the estimated text size, about 3x the staged
  size) are refused (narrow the query); above 2 GB the link is returned with
  a large-download warning.

`export_query` sizes the result itself, whether or not `stage_query` was
called, and refuses before anything is exported. Results above the export cap
are refused outright: the cap limits the transfer, so splitting the request
into parts is not a way round it. An export that writes more than 1 GB in the
chosen format (CSV text is estimated at about 3x the staged size; locally, a
dataset written as CSV/Parquet is sized as the flattened table, and a limited
one on its limited slice) is refused unless `confirm_size_bytes` equals that
size. On hosted servers a value within 5% is accepted, because the download
stage is sized separately from `stage_query`'s stage. The refusal reports the
exact value as `export_size_bytes`. The agent should pass it only after the
user has agreed. The server cannot see whether the user was asked, so the check
is advisory: it forces an explicit, size-aware step for each large export.

Accepts the same query parameters as `query_data` plus:

| Parameter   | Type   | Description                                                              |
| ----------- | ------ | ------------------------------------------------------------------------ |
| `path`      | string | Destination file path (parent directories are created)                   |
| `format`    | string | `netcdf` (datasets), `parquet` or `csv` (tabular); sensible default      |
| `overwrite` | bool   | Overwrite an existing file (default false)                               |
| `confirm_size_bytes` | int | Bytes (in the chosen format) the user agreed to export; required above 1 GB; exact match (within 5% on hosted) |

### `load_datasource`

Summarize an entire datasource. Gridded datasources are opened lazily (no data
download); tabular datasources are downloaded only if under the inline size limit.

| Parameter       | Type   | Description        |
| --------------- | ------ | ------------------ |
| `datasource_id` | string | Datasource to load |

### `update_metadata`

Update metadata on an existing datasource. Only provided fields are changed.
Disabled when the server runs with `OCEANUM_MCP_READ_ONLY` set.

| Parameter       | Type         | Description                  |
| --------------- | ------------ | ---------------------------- |
| `datasource_id` | string       | Datasource to update         |
| `name`          | string       | New name                     |
| `description`   | string       | New description              |
| `tags`          | list[string] | New tags                     |
| `labels`        | list[string] | New labels                   |
| `info`          | object       | Additional metadata object   |
| `details`       | string       | URL for datasource details   |

## Storage Tools

### `list_files`

List files and directories in Oceanum cloud storage.

| Parameter   | Type   | Description                           |
| ----------- | ------ | ------------------------------------- |
| `path`      | string | Directory path to list (default: "/") |
| `recursive` | bool   | List subdirectories recursively       |

### `file_exists`

Check if a file or directory exists in storage.

| Parameter | Type   | Description   |
| --------- | ------ | ------------- |
| `path`    | string | Path to check |

### `read_file`

Read the contents of a text file from storage.

| Parameter | Type   | Description      |
| --------- | ------ | ---------------- |
| `path`    | string | Path to the file |

### `write_file`

Write text content to a file in storage.

| Parameter | Type   | Description           |
| --------- | ------ | --------------------- |
| `path`    | string | Destination path      |
| `content` | string | Text content to write |

### `delete_file`

Delete a file or directory from storage.

| Parameter   | Type   | Description                           |
| ----------- | ------ | ------------------------------------- |
| `path`      | string | Path to delete                        |
| `recursive` | bool   | Delete directory contents recursively |

### `file_info`

Get metadata about a file or directory.

| Parameter | Type   | Description     |
| --------- | ------ | --------------- |
| `path`    | string | Path to inspect |

## Example Workflows

**Discover wave data in the Pacific:**

1. `search_catalog(search="wave", bbox=[120, -50, 180, 10])`
2. `get_datasource_info(datasource_id="some-wave-dataset")`
3. `stage_query(datasource_id="some-wave-dataset", variables=["Hs", "Tp"], time_start="2024-01-01", time_end="2024-01-31")` to check the result size
4. `query_data(...)` with the same parameters if small, or `export_query(..., path="waves.nc")` if large

**Shrink a 40-year hourly time series to something inline-sized:**

1. `stage_query(datasource_id="hindcast", variables=["Hs"], time_start="1984-01-01", time_end="2024-01-01")` — too large
2. `query_data(..., time_resolution="30D", time_resample="mean")` — roughly monthly means, small enough to return inline (calendar aliases such as `"1MS"` are refused until a Datamesh engine fix ships: the engine currently reads `"1MS"` as 1 millisecond)

**Browse and read files in cloud storage:**

1. `list_files(path="/")` to see top-level contents
2. `list_files(path="/my-project", recursive=True)` to drill down
3. `read_file(path="/my-project/config.json")` to read a file

**Get a quick summary of a dataset:**

1. `get_datasource_info(datasource_id="my-dataset")` to see variables and time range
2. `query_data(datasource_id="my-dataset", limit=10)` to preview the data

TDQS

A3.7/5.0

Scored across 13 tools

Disambiguation4/5

The data-access tools are clearly tiered by role: search_catalog finds datasources, get_datasource_info/stage_query inspect metadata and size, query_data returns inline previews, and export_query offloads large results. The only real friction is load_datasource ('summarize an entire datasource') versus get_datasource_info ('metadata for one datasource'), which can look interchangeable at a glance, plus the storage_file_exists/storage_file_info pair.

Naming Consistency4/5

Every name is snake_case with a clear domain prefix (datamesh_/storage_) and most follow verb_noun (query_data, export_query, stage_query, list_files, read_file, delete_file). The deviations are minor: load_datasource rather than get_datasource, and the noun-first storage_file_exists/storage_file_info.

Tool Count5/5

Thirteen tools split cleanly into two domains (7 datamesh, 6 storage), each earning its place in the query/export workflow. Nothing feels redundant or padded at this size.

Completeness4/5

The data pipeline is covered end-to-end (discover → inspect → stage → query → export → update metadata) and storage has full create/read/list/delete/stat coverage. Gaps remain: no datasource creation or deletion, no storage copy/move, and metadata updates are the only write path for the catalog.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive