Read-only mirror of https://github.com/DCC-BS/mcp-data-bs — Basel-Stadt. Issues & pull requests at the source.
  • Python 96.6%
  • Dockerfile 3.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Yanick Schraner fcf8d683c4
Merge pull request #8 from DCC-BS/feat/agent-citable-results
Citable, compact tool results for LLM agents
2026-10-12 09:13:30 +02:00
.github/workflows Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
skills/data-bs Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
tests Fix CI 2026-10-12 09:10:18 +02:00
.env.example Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
.gitignore Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
api_docs.py feat: MCP 2.x migration, /mcp path, OpenAPI docs, relaxed Accept header (#6) 2026-10-08 10:42:12 +02:00
CHANGES.md Make results citable and compact for LLM agents 2026-10-09 16:04:40 +02:00
compose.yml Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
Dockerfile Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
main.py Make results citable and compact for LLM agents 2026-10-09 16:04:40 +02:00
mise.toml Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
pyproject.toml Feat/hostable mcp and skills (#4) 2026-09-22 15:35:37 +02:00
README.md Make results citable and compact for LLM agents 2026-10-09 16:04:40 +02:00
uv.lock feat: MCP 2.x migration, /mcp path, OpenAPI docs, relaxed Accept header (#6) 2026-10-08 10:42:12 +02:00

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 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):

  • .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 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 at https://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_DOMAIN in your environment first (the .env is no longer committed). uvx runs 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 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

npx @modelcontextprotocol/inspector uv run main.py