- Python 96.6%
- Dockerfile 3.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| skills/data-bs | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| api_docs.py | ||
| CHANGES.md | ||
| compose.yml | ||
| Dockerfile | ||
| main.py | ||
| mise.toml | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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. Enter the project and trust
the config once:
mise trust
mise install # provisions uv, runs the postinstall hook
Then set up the virtual environment and dependencies:
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
# .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).
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 atools/callrequest 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 localhostHostheaders — a remote deploy would get421 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 anyHostheader 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):
.github/workflows/ci.ymlrunsmise run checkandmise run test:unitautomatically on push/PR (tasks absent frommise.tomlare skipped)..github/workflows/publish.yml(manualworkflow_dispatch) builds and pushes to GHCR usingpublish-docker.yml@v2. Bump the version inpyproject.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 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:
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:
{
"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 athttps://mcp.data.bs.ch/docs.
Other clients
- opencode: add to OpenCode config:
{ "mcpServers": { "data-bs": { "command": "uv", "args": ["--directory", "/ABSOLUTE/PATH/TO/data-bs-mcp", "run", "main.py"] } } } - uvx (anywhere): set
DATA_PORTAL_DOMAINin your environment first (the.envis no longer committed).uvxruns the same code as a local checkout.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 thevector_similarityexplore 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
npx @modelcontextprotocol/inspector uv run main.py