Skip to main content
Glama
DCC-BS
by DCC-BS
README.md
# data-bs-mcp

MCP server for any Huwise/Opendatasoft data portal — query open datasets from
data.bs.ch (and other portals on the same platform) through the Explore 2.1 API.

It runs in two modes:

- **stdio** — for local MCP clients (opencode, Cursor, Claude Desktop).
- **streamable HTTP** — hosted as a container, so it can be wired into ChatGPT
  connectors and OpenWebUI. Also ships a **skill** as a no-server alternative.

## Installation

The toolchain (uv only; Python version comes from `pyproject.toml`) and the task
runner are managed by [mise](https://mise.jdx.dev/). Enter the project and trust
the config once:

```bash
mise trust
mise install            # provisions uv, runs the postinstall hook
```

Then set up the virtual environment and dependencies:

```bash
mise run install        # alias: i — uv sync --locked
```

The `postinstall` hook runs `install` automatically on tool provisioning.

## Tasks

All standard DCC task names are available via `mise run <task>`; see
`mise.toml`. In Docker the `install` task syncs `--no-dev` (driven by
`DOCKER_BUILD=1`).

| Task                 | Alias | Description                                       |
|----------------------|-------|---------------------------------------------------|
| `mise run install`   | `i`   | Create venv and install deps (`uv sync --locked`) |
| `mise run dev`       | `d`   | MCP server over stdio (local clients)             |
| `mise run dev:http`  | `dh`  | Streamable HTTP on `:8000` for local testing      |
| `mise run check`     | `c`   | Verify lockfile, format, and lint (ruff)          |
| `mise run test:unit` | `t`   | Run the unit test suite (pytest)                  |

## Selecting a catalog

The catalog is chosen by whoever runs the server. Configuration is read from the
`DATA_PORTAL_DOMAIN` environment variable first, else a local `.env` file next to
`main.py`. Copy the example and fill it in:

```
cp .env.example .env
```

```bash
# .env
DATA_PORTAL_DOMAIN=data.bs.ch
MCP_ALLOWED_HOSTS=mcp.bs.ch:*   # optional, see Hosting
```

The API base URL is built as `https://<domain>/api/explore/v2.1`. All
Huwise/Opendatasoft portals share this path, so changing the domain targets a
different portal.

## Hosting (streamable HTTP)

Deploy as a container. The image is built against the DCC shared base image
(`ghcr.io/dcc-bs/dcc-docker-images/mise:13-slim`); see `Dockerfile`.

The MCP endpoint is mounted at `/mcp` (streamable HTTP).

```bash
docker build -f Dockerfile -t mcp-data-bs .
docker run --rm -p 8000:8000 -e DATA_PORTAL_DOMAIN=data.bs.ch mcp-data-bs
```

Healthcheck: `GET /healthz -> {"status":"ok"}`.

### API reference

The HTTP server exposes an OpenAPI description of the MCP endpoint with a
rendered Swagger UI:

- `GET /docs` — interactive reference (Swagger UI). Each tool appears as a
  `tools/call` request schema with a working example you can execute against
  the server via *Try it out*.
- `GET /openapi.json` — the raw OpenAPI 3.1 spec.

The spec is generated from the live MCP tool registry at request time, so new
tools appear automatically. It covers all endpoints (`POST /mcp`, `GET /healthz`,
`GET /`). Responses are `text/event-stream`; each `data:` frame carries a
JSON-RPC message. The server is stateless — requests need no `initialize`
handshake and no session id.

Client compatibility: POST requests with incomplete `Accept` headers (e.g. only
`application/json` or `*/*`) are rewritten to
`application/json, text/event-stream` by a middleware before the transport
validates them, so plain JSON-RPC clients work unmodified. `GET /` returns a
JSON status page (server, transport, endpoint links) instead of holding an SSE
connection open — clients expecting a traditional SSE handshake get an
immediate, meaningful response.

> **Remote hosting requires `MCP_ALLOWED_HOSTS`.** The MCP HTTP endpoint has
> DNS-rebinding protection on by default and, without config, accepts only
> localhost `Host` headers — a remote deploy would get `421 Invalid Host header`.
> When the server is reachable via a public hostname, list it (comma-separated,
> port-wildcard allowed): `MCP_ALLOWED_HOSTS="mcp.bs.ch:*"`. When unset,
> protection is disabled so any `Host` header is accepted.

### Docker Compose

`compose.yml` pulls the published GHCR image, sets the domain and allowed
hostname(s), and healthchecks: `docker compose up -d`.

### Publishing (CI)

This repo uses the DCC reusable workflows ([ci-workflows](https://github.com/DCC-BS/ci-workflows)):

- `.github/workflows/ci.yml` runs `mise run check` and `mise run test:unit`
  automatically on push/PR (tasks absent from `mise.toml` are skipped).
- `.github/workflows/publish.yml` (manual `workflow_dispatch`) builds and pushes
  to GHCR using `publish-docker.yml@v2`. Bump the version in `pyproject.toml`,
  then dispatch to tag the image `<version>` + `latest`.

## Connecting clients

The snippets below are self-contained: stdio clients launch the server from
GitHub via `uvx` (needs [uv](https://docs.astral.sh/uv/) installed), HTTP
clients point at the hosted deployment. Set `DATA_PORTAL_DOMAIN` to another
portal domain to target a different catalog.

**Antigravity CLI (`agy`)** — one command:

```bash
agy mcp add --env DATA_PORTAL_DOMAIN=data.bs.ch databs -- \
  uvx --from git+https://github.com/DCC-BS/mcp-data-bs data-bs-mcp
```

**Claude Desktop / Cursor** — add to `claude_desktop_config.json`
(Claude Desktop → Settings → Developer → Edit Config) or `~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "data-bs": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/DCC-BS/mcp-data-bs", "data-bs-mcp"],
      "env": { "DATA_PORTAL_DOMAIN": "data.bs.ch" }
    }
  }
}
```

**OpenWebUI / ChatGPT** — point at the streamable HTTP endpoint
`https://mcp.data.bs.ch/mcp` (no auth):

- OpenWebUI: Settings → Tools → add `https://mcp.data.bs.ch/mcp`
- ChatGPT (developer-mode connector): Settings → Connectors → Create → add
  `https://mcp.data.bs.ch/mcp`; all five tools are exposed and usable, and the
  interactive API reference lives at `https://mcp.data.bs.ch/docs`.

### Other clients

- **opencode**: add to OpenCode config:
  ```json
  {
    "mcpServers": {
      "data-bs": {
        "command": "uv",
        "args": ["--directory", "/ABSOLUTE/PATH/TO/data-bs-mcp", "run", "main.py"]
      }
    }
  }
  ```
- **uvx** (anywhere): set `DATA_PORTAL_DOMAIN` in your environment first (the
  `.env` is no longer committed). `uvx` runs the same code as a local checkout.
  ```bash
  DATA_PORTAL_DOMAIN=data.bs.ch \
    uvx --from git+https://github.com/DCC-BS/mcp-data-bs data-bs-mcp
  ```

## Skills (no MCP needed)

Prefer not to run a server? Install the agent skill instead — it teaches an
agent to hit the public REST API directly with plain HTTP (curl/httpx). See
`skills/data-bs/SKILL.md`; copy or symlink it into your agent's skills directory
(e.g. `~/.agents/skills/data-bs`).

## Tools

### `get_datasets`
Search and list available datasets.

Two search modes:
- `semantic` (default): ranks the catalog by meaning using the `vector_similarity` explore endpoint from Huwise. Best for natural-language / conceptual queries. Matches synonyms and other languages.
- `lexical`: classic full-text match on the exact terms.

```
# semantic (default) — natural language, ranked by relevance
get_datasets(search="air quality measurements")

# lexical — exact full-text match
get_datasets(search="luft", search_mode="lexical")

# combine with facet filters
get_datasets(search="bevölkerung", refine="publisher:Statistisches Amt")
```

`get_datasets` also returns `next_offset` for paging (null when there is no next page).

### `get_dataset`
Get detailed metadata for a specific dataset (fields with name, type, label, short description, unit, sortable/facet flags; publisher; `url`, `title`, `modified`).

```
get_dataset(dataset_id="100113")
```

### `get_records`
Query records from a dataset with ODSQL filtering. The result is citable on its own: `dataset_id`, `url`, `title`, `modified` (the dataset's Stand; title and modified come from a cached catalog lookup and are omitted if it fails), plus `total_count`, `next_offset` (null on the last page) and `results`. Output is capped at about 12,000 characters; if rows were cut, `truncated: true` and a `hint` say so (use `select`, `group_by`, a smaller `limit` or `next_offset`).

```
get_records(dataset_id="100113", where="pm25 > 10", limit=100, order_by="time DESC")
```

### `get_facets`
Get available facet values for filtering. Without `facet`, every facet is returned with at most 30 values and `total_values`; with a name, up to 100.

```
get_facets(facet="publisher")  # Options: publisher, keyword, theme, features, modified, language
```

### `export_dataset_url`
Get download URL for dataset export. Returns an object `{download_url, format, dataset_id, url, title, modified}` (older versions returned the URL as a plain string).

```
export_dataset_url(dataset_id="100113", format="csv", where="sensornr=240")
```

Formats: `csv`, `json`, `geojson`, `xlsx`, `shp`, `parquet`, `gpx`, `kml`, `rdfxml`, `jsonld`, `turtle`

## Debug

```bash
npx @modelcontextprotocol/inspector uv run main.py
```

TDQS

A3.9/5.0

Scored across 5 tools

Disambiguation4/5

Each tool targets a distinct stage: search (get_datasets), metadata (get_dataset), facets (get_facets), records (get_records), and export (export_dataset_url). Minor potential confusion exists between get_datasets and get_facets for discovery, but descriptions clarify their different purposes.

Naming Consistency5/5

All tools follow a verb_noun pattern, with four using 'get_' and one using 'export_', both being clear verbs. The naming is consistent and predictable across the set.

Tool Count5/5

Five tools is well-scoped for a data portal: search, metadata, facets, records, and export cover the core read workflows without unnecessary bloat.

Completeness5/5

The set covers the full read lifecycle: discover datasets, understand their structure, query records, and export data. No obvious missing operations for a public read-only open data portal.

Maintenance

ActivityMaintained
ResponsivenessNo issues