Swiss Grounding MCP
MCP serverEverything elseCited answers about Switzerland from official federal, cantonal and municipal sources.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use Swiss Grounding MCP
Install Swiss Grounding MCP
The server’s own address, for the clients that take one directly. Or connect ahel onceand every client you use reads it from one address, with the account kept on ahel rather than in each client’s config.
Claude Code
claude mcp add --transport http --scope user swiss-grounding-mcp 'https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcp'Run it once in your project, then open /mcp to approve any sign-in the server asks for.
Claude Desktop
https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcpAdd a custom connector in Settings, paste this address, and approve the sign-in.
Cursor
cursor://anysphere.cursor-deeplink/mcp/install?name=swiss-grounding-mcp&config=eyJ1cmwiOiJodHRwczovL3N3aXNzLWdyb3VuZGluZy1tY3AtNTQyNjMwOTg2NDE1LmV1cm9wZS13ZXN0Ni5ydW4uYXBwL21jcCJ9Open the link and Cursor adds the server at that address.
ChatGPT
https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcpIn Settings, enable Developer mode, create an MCP app, and paste this address. Your plan and workspace must allow custom apps.
Codex
codex mcp add swiss-grounding-mcp --url 'https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcp'Run it once, then sign in with codex mcp login swiss-grounding-mcp if the server asks for an account.
From the project's README
As published by Gastaan/swiss-grounding-mcp in README.md.
An MCP server that gives AI assistants authoritative, cited, jurisdiction-correct answers about Switzerland — from federal, cantonal and municipal sources, in German, French, Italian, Romansh and English. Built for the Swisscom Swiss Grounding MCP challenge (Swiss AI Weeks, Zurich 2026).
- No API keys, no accounts. Everything is public Swiss open data or official web pages.
- 13 read-only tools, one response contract, citations on every result.
- Honest by design: asks back only for missing essentials (e.g. the municipality), says clearly when something is not covered or not in Switzerland, never fills gaps from memory.
- Runs locally over stdio or as a Streamable HTTP service (
/mcp,/health).
Quick start
Requires uv (it installs Python 3.13 automatically).
git clone https://github.com/Gastaan/swiss-grounding-mcp && cd swiss-grounding-mcp
uv sync --extra semantic # dependencies, with hybrid search
uv run --extra semantic swiss-grounding-mcp # stdio (for local MCP clients)
uv run --extra semantic swiss-grounding-mcp --transport http --port 8000 # HTTP: http://localhost:8000/mcp
curl localhost:8000/health # "search": "hybrid (…)"
The prebuilt data (municipality register, health premiums, search index, passage vectors) ships in the repository; the first start unpacks the index (~1 s) and downloads the small embedding model once (~240 MB, in the background: searches use keywords until it is ready). No keys, nothing to configure.
Lighter install without hybrid search (no model, ~130 MB fewer packages): drop --extra semantic from
the commands above and from the client configuration below. See Search for the difference.
Docker:
docker build -t swiss-grounding-mcp .
docker run -p 8000:8000 swiss-grounding-mcp # http://localhost:8000/mcp
The image (~1.4 GB, including hybrid search and its model) listens on all interfaces
(HOST=0.0.0.0) as an unprivileged user and never downloads anything at runtime. When its port is
reachable from outside a trusted network, add -e SGM_AUTH_TOKEN=<secret> (see HTTP security).
Published image (GitHub Container Registry, same build): docker run -p 8000:8000 ghcr.io/gastaan/swiss-grounding-mcp
over HTTP, or docker run -i --rm ghcr.io/gastaan/swiss-grounding-mcp --transport stdio for an MCP
client that starts the server itself.
Without cloning (keyword search): uvx --from git+https://github.com/Gastaan/swiss-grounding-mcp swiss-grounding-mcp.
Hosted endpoint
A public instance runs on Google Cloud Run in Zürich (europe-west6), deployed from this repository's
main branch (the revision carries the commit as the label git-commit):
- MCP endpoint (Streamable HTTP):
https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcp - Health:
https://swiss-grounding-mcp-542630986415.europe-west6.run.app/health· landing page:https://swiss-grounding-mcp-542630986415.europe-west6.run.app/
claude mcp add --transport http swiss https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcp
No token is needed (read-only public information; rate-limited per client, browser origins refused).
It scales to zero when idle, so the first request after a pause starts an instance (about 2 s; hybrid
search follows about 10 s later, keyword search answers meanwhile). A scheduled workflow
(.github/workflows/hosted-check.yml) checks it every 6 hours. It is listed in the official MCP
Registry as io.github.soheil1lotfi/swiss-grounding-mcp (server.json), with this hosted endpoint and the
published Docker image. The code in this repository runs
locally with the setup below; the hosted instance runs the same commit.
To redeploy after a change to main (maintainers, with access to the Google Cloud project):
git archive origin/main | tar -x -C /tmp/sgm-deploy
gcloud run deploy swiss-grounding-mcp --source /tmp/sgm-deploy --region europe-west6 --memory 2Gi \
--cpu 1 --cpu-boost --max-instances 3 --concurrency 40 --allow-unauthenticated \
--set-env-vars 'FORWARDED_ALLOW_IPS=*'
Connect an MCP client
Use absolute paths. Replace /path/to/swiss-grounding-mcp with your clone.
OpenCode (opencode.json):
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"swiss": {"type": "local", "command": ["uv", "run", "--extra", "semantic", "--directory", "/path/to/swiss-grounding-mcp", "swiss-grounding-mcp"], "enabled": true, "timeout": 30000},
"swiss-http": {"type": "remote", "url": "http://localhost:8000/mcp", "enabled": false, "timeout": 30000}
}
}
Set timeout: OpenCode waits only 5 s for an MCP request by default, while a slow official source can
take longer; the server ends every tool call within SGM_TOOL_TIMEOUT (30 s) with a clean
source_error, so a client timeout of 30 s or more lets that answer arrive.
Claude Code:
claude mcp add swiss -- uv run --extra semantic --directory /path/to/swiss-grounding-mcp swiss-grounding-mcp
claude mcp add --transport http swiss-http http://localhost:8000/mcp
Claude Desktop (claude_desktop_config.json; give the full path to uv, e.g. from which uv):
{"mcpServers": {"swiss": {"command": "/full/path/to/uv", "args": ["run", "--extra", "semantic", "--directory", "/path/to/swiss-grounding-mcp", "swiss-grounding-mcp"]}}}
VS Code (.vscode/mcp.json):
{"servers": {"swiss": {"type": "stdio", "command": "uv", "args": ["run", "--extra", "semantic", "--directory", "/path/to/swiss-grounding-mcp", "swiss-grounding-mcp"]}}}
Cursor (.cursor/mcp.json):
{"mcpServers": {"swiss": {"command": "uv", "args": ["run", "--extra", "semantic", "--directory", "/path/to/swiss-grounding-mcp", "swiss-grounding-mcp"]}}}
Any client that speaks MCP over stdio or Streamable HTTP works. Tested with Claude Code, OpenCode,
the MCP Inspector CLI and the FastMCP client, including the legacy initialize handshake
(protocol 2025-06-18) and the stateless 2026-07-28 protocol. The server sends usage instructions.
Coverage (declared scope)
| Topic | Geography | Source (authority) | Freshness / reference period |
|---|---|---|---|
| Procedures, rules, fees, deadlines — permits & migration, moving & registration, taxes, social insurance (AHV/IV), unemployment, driving licences & vehicles, customs & parcels, schools, housing, voting, civil status… | Federal (ch.ch in de/fr/it/rm/en, federal offices, AHV/IV, arbeit.swiss), cantonal portals of 23 cantons (see limitations), city pages of Lucerne, Lugano, Winterthur, Biel/Bienne, St. Gallen, Bern, Geneva, Lausanne and Thun | Full-text index of 10,630 official pages / 46,952 passages, plus live reading of any official page | Index built 2026-09-25, refreshed weekly; read_official_page fetches live text |
| Federal law — any act and article, current consolidated version | Federal | Fedlex (Federal Chancellery) | Live; version in force today |
| Mandatory health insurance premiums (cheapest offers per municipality, age, deductible, model) | All 2,110 municipalities (premium regions) | FOPH premium open data (same data as priminfo.admin.ch) | 2026 premiums; 2027 added when FOPH publishes them (end of September) |
| School holidays and public holidays | All 26 cantons; municipality level where published (e.g. Scuol, Zürich) | OpenHolidays (aggregated official lists), EDK list, municipality website | 2025–2027 |
| Waste collection dates | City of Zürich (by postcode), Basel/Riehen/Bettingen (by address), St. Gallen (by street); by collection zone: Winterthur, Uster, Wetzikon, Dübendorf, Horgen, Wädenswil, Adliswil, Thalwil and 13 more | Municipal open data (ERZ Zürich via OpenERZ, data.bs.ch, daten.stadt.sg.ch) | Live, next 120 days |
| Public transport connections and departure boards | All of Switzerland | Official timetable (opentransportdata.swiss via transport.opendata.ch) | Live |
| Federal popular votes — upcoming subjects, results (national + canton) | Federal | FSO vote-day open data, Federal Chancellery | Live |
| Mortgage reference interest rate (rents); SNB exchange rates | Federal | BWO; Swiss National Bank | Live (cached 6 h) |
| Company registration — UID, legal seat, commercial register and VAT status | All of Switzerland | Federal UID register (FSO) | Live |
| Place facts — municipality, canton, BFS number, postcodes, population, official website | All 2,110 municipalities, 26 cantons | BFS register & STATPOP, swisstopo, Wikidata (websites) | Population 2025; register 2026-09-25 |
| Current weather measurements | Nearest MeteoSwiss automatic station | MeteoSwiss open data | Live (10-minute values) |
Not covered / limitations
- Anything outside Switzerland — e.g. the German Rundfunkbeitrag in Konstanz. The server says so.
- Cantons GR, BL and SH block or do not serve text to automated clients, so their cantonal
pages are not in the index (ch.ch and federal pages still apply;
read_official_pagereports the block honestly). VS (7 pages), TI (45) and TG (52) are only partly indexed. - Municipal web pages are indexed for Lucerne, Lugano, Winterthur, Biel/Bienne, St. Gallen, Bern, Geneva, Lausanne and Thun (120–250 pages each; Lausanne 66, Thun 32). Zürich has only a few pages, and Bellinzona and Fribourg none yet (the next refresh crawls Fribourg's own domain, ville-fribourg.ch). For other municipalities the server returns the official website and can read a given page live; waste, holidays, premiums and place facts cover all municipalities through their tools.
- Cantonal law texts, individual tax calculations and weather forecasts are not provided.
- Waste calendars exist only where municipalities publish open data (list above); elsewhere the server says so and links the municipality.
- School holidays come from OpenHolidays, which aggregates official lists. Where the index holds the responsible authority's own calendar (e.g. ge.ch, bern.ch, the school of Scuol), that page is cited first; the EDK list and the municipality site are cited alongside. Periods are labelled by school type where a canton publishes several (canton Bern: German- and French-speaking schools).
- Registering on arrival in Lausanne is weak: the city's residents' office page is not in the index.
- A question in another language than the place's pages first returns a language hint, not the page: the assistant has to search again. In end-to-end runs of such a question (Q17, French question about Bern), Sonnet followed the hint and answered from Bern's page; Haiku did so in 1 of 4 runs.
- Search is keyword-based by default. With the optional
semanticextra it is hybrid and also finds pages worded differently or written in another language, but still misses some (see Search); Romansh is not covered by the embedding model.
Challenge self-check
The organisers' practice cases (read from the published file; their launcher was not run), checked against this server:
| Practice case | Behaviour |
|---|---|
| Cardboard collection, no place given | asks only for the municipality (needs_context), never a date |
| Geneva school holidays 2026 | cites ge.ch/vacances-scolaires-2026-2027 first, no ask-back; periods the official page confirms are marked on_official_page |
| Licence fee in Konstanz | "Konstanz is in Germany, Swiss sources do not apply" (not_covered) |
| Romansh: autumn holidays in Scuol | 10–25 Oct 2026, citing the Scuol school's own 2026/27 plan |
| Current reference interest rate | BWO page, 1.25 % with its effective date, cached at most 6 h |
| Registering on arrival in Lausanne, then Bern | Bern: the French question gets a hint that Bern publishes in German; searching again in German finds the city's own page (bern.ch/themen/zuzug-umzug-wegzug). Lausanne: the city's residents' office page is not indexed, so answers rest on the federal ch.ch page and the canton's pages |
| Which source supports a deadline | every citation carries the verbatim passage (excerpt) |
| Source unavailable | a cached copy is labelled with its date (data.stale_sources), otherwise source_error with the link |
End to end (scripts/e2e_eval.py --questions eval/practice_questions.json, Claude Code, answers
also reviewed by hand against the pack's criteria, eval/results/2026-09-25T0704.md): Sonnet 11/11,
Haiku 10/11. The practice cases are in eval/practice_questions.json, together with the pack's three
extra sample questions and a source-failure case (the server offline with an empty cache: both models
said the housing office was unreachable, gave its link and did not guess a rate). Haiku's miss: the
notice-period answer is right but cites ch.ch instead of the article on Fedlex. Reviewing the answers
found one error the automated checks had passed: the City of Bern's autumn holidays were given with the
French-speaking schools' dates. Holiday periods are now checked against the official page (the one on
bern.ch confirms 19 September to 11 October 2026) and school types are named by language, and both
models answer correctly.
Robots.txt and terms of use are respected by default and both are configurable (SGM_RESPECT_ROBOTS,
SGM_RESPECT_TERMS, see Configuration). No credentials are needed.
Search
search_official_info ranks passages of the index with SQLite FTS5/BM25 plus rules (the user's
language, the most specific jurisdiction, how many query words a passage covers). With the optional
semantic extra it becomes hybrid: a local multilingual embedding model
(paraphrase-multilingual-MiniLM-L12-v2, ONNX, no API key) ranks the same passages by meaning, and the
two rankings are merged (reciprocal-rank fusion, with the same preference for cantonal and municipal
pages when a place is given). The two best keyword hits keep their places, so exact matches are never
pushed out: with only one, passages about a neighbouring rule (the travellers' CHF 150 allowance) took
the place of the CHF 5 parcel rule.
uv sync --extra semantic # then start the server as usual; /health shows "search": "hybrid (…)"
Measured with scripts/search_eval.py: 38 hand-labelled questions whose answer is in the index (including
the challenge's practice cases), plus 5 without one. A question counts as answered when an official page on its topic is among the 5 results
the tool returns; for the "rule" questions the excerpt itself must state the rule (the CHF 5 parcel-VAT
rule behind end-to-end question Q12).
| Question kind | Keyword only | Hybrid |
|---|---|---|
| Uses the page's own words (9) | 9 | 9 |
| Same language, other words (9) | 5 | 6 |
| Another language than the only official page (15) | 2 | 8 |
| The rule itself in an excerpt (5 phrasings of Q12) | 3 | 2 |
| Answered (38) | 19 | 25 |
| No correct page exists: says so instead of passing off another page (5) | 5 | 5 |
For example, "exchange my foreign driving licence" in Lausanne now finds Vaud's French-only page, and "register my dog" in Basel finds the German one. Still missed: the Romansh school calendar of Scuol, German or English questions about Lausanne's French waste calendar, and parcel-VAT questions worded with terms the official pages do not use ("Freigrenze", French "colis").
Honesty is kept: a result only counts as evidence if it covers at least half of the query's words
or is close in meaning (cosine ≥ 0.7). With no such result the tool answers not_found, as before.
The threshold was set so that none of the questions without a correct page gets through by similarity
alone.
Costs: ~240 MB for the model (downloaded once into SGM_MODEL_DIR, or at image build time in
Docker) and ~130 MB of Python packages (fastembed, onnxruntime, numpy and their dependencies); data/embeddings.npz adds 12 MB
(int8 vectors); about 20 ms per search; the weekly refresh re-embeds all passages (~15 min on a laptop,
longer on CI runners). Without the extra, or with SGM_SEMANTIC=off, nothing of this is loaded.
The server only uses embeddings built from the exact index it serves, and falls back to keyword search
if the vector part fails.
Tools
| Tool | Use it for |
|---|---|
search_official_info | "How do I…", rules, deadlines, fees — verbatim excerpts from official pages, filtered to federal + the given canton/municipality |
read_official_page | Live text of an official page (allowlisted Swiss government domains only), focused on given words |
swiss_federal_law | Federal law articles (SR number or abbreviation such as OR/CO, ZGB, SVG, AIG) |
health_insurance_premiums | Cheapest KVG/LAMal premiums |
swiss_holidays | School or public holidays |
waste_collection | Next collection dates by waste type |
public_transport | Connections and departures |
federal_votes | Next vote subjects or results |
swiss_rates | Reference interest rate, SNB exchange rates |
company_register | Company lookup by name or UID |
swiss_place_info | Municipality facts, population, website |
current_weather | Latest MeteoSwiss measurements |
swiss_coverage | The scope above, for the assistant |
Response contract
Every tool returns the same JSON object (as structuredContent with an outputSchema, and as text):
{
"status": "ok | needs_context | not_covered | not_found | source_error",
"summary": "One factual sentence, ending with 'Source: <publisher> – <url>'",
"data": {"...": "tool-specific facts"},
"citations": [{"title": "", "url": "", "publisher": "", "level": "federal|cantonal|municipal|semi-official|community",
"jurisdiction": "CH | CH-VD | CH-VD-5586", "retrieved_at": "", "valid_for": "", "excerpt": "verbatim"}],
"missing_context": [{"field": "municipality", "question": "…", "options": ["Buchs (ZH)", "Buchs (SG)"]}],
"guidance": "what the assistant should do next"
}
needs_context— ask the user exactlysummary(e.g. which municipality; which of three Buchs).not_covered— outside Switzerland or outside the declared scope; the assistant should say so.not_found/source_error— nothing found / source unreachable; never answer from memory.- Places can be given as the user wrote them: Genf, Ginevra, Genève, Schuls → Scuol, 8003, Zurich 8003, Bahnhofstrasse 1, Zürich. Ambiguous names return options; foreign places are flagged.
- Invalid arguments are returned with
isError: trueand a readable message. data.stale_sourcesappears when a live source was down and an earlier cached copy was used (at mostSGM_MAX_STALE_HOURSold);guidancethen tells the assistant to give the user that date.search_official_infosetsdata.local_match: falsewhen a place was given but no cantonal or municipal page matched as well as the federal ones; the summary says so and the municipality's website is cited, so a federal page is not passed off as the local rule.search_official_infosetsdata.place_languages(e.g.["de"]) when the question is in another language than the place publishes in and none of the place's own pages matched. The summary and guidance then ask the assistant to search again with its key words translated into that language: a French question about registering in Bern leads to a German search that finds Bern's own page. The place's languages come from the language of its pages in the index.- The output schema lists the top-level fields only; the fields inside
citationsandmissing_contextare described once in the server instructions, which keepstools/listsmall.
Configuration
| Variable | Default | Meaning |
|---|---|---|
SGM_RESPECT_ROBOTS | true | Respect robots.txt of every website fetched (RFC 9309, via Protego), on every redirect hop. Set false to disable. |
SGM_RESPECT_TERMS | true | Respect terms of use recorded in data/source_terms.json: hosts whose terms do not allow automated access are never fetched (e.g. the zefix.ch web application; company data comes from the official UID register web service). Set false to disable. |
SGM_OFFLINE | false | Serve from cache only, never hit the network. |
SGM_CACHE_DIR | ~/.cache/swiss-grounding-mcp | HTTP response cache. |
SGM_CACHE_MAX_DAYS, SGM_CACHE_MAX_MB | 30, 500 | At startup, entries untouched this long are deleted, then the oldest until the cache fits. |
SGM_MAX_STALE_HOURS | 168 | When a source is down, serve an expired cached copy up to this old, labelled as stale. 0 disables. |
SGM_HTTP_TIMEOUT | 15 | Seconds per upstream request. |
SGM_TOOL_TIMEOUT | 30 | Seconds for a whole tool call, however many upstream requests it makes. |
SGM_MIN_INTERVAL | 0.5 | Minimum seconds between requests to the same host. |
SGM_USER_AGENT | Mozilla/5.0 (compatible; SwissGroundingMCP/<version>; +<repo URL>) | Standard crawler form: names the project and links to it. |
SGM_TRANSPORT, HOST, PORT | stdio, 127.0.0.1, 8000 | Same as --transport/--host/--port. The Docker image sets HOST=0.0.0.0. |
SGM_AUTH_TOKEN | unset | HTTP only: require Authorization: Bearer <token> on /mcp and /metrics (/health stays open). |
SGM_ALLOWED_ORIGINS | none | HTTP only: comma-separated browser origins allowed to call /mcp (e.g. a web inspector). |
SGM_RATE_LIMIT | 600 | HTTP only: requests per minute per client IP. 0 disables. |
SGM_SEMANTIC | auto | auto: hybrid search when the semantic extra and data/embeddings.npz are present; off: keyword only. |
SGM_MODEL_DIR | ~/.cache/swiss-grounding-mcp/models | Where the embedding model is stored (the Docker image bakes it into /app/models). |
SGM_LOG_LEVEL | INFO | Logs go to stderr: one line per tool call (tool, status, bytes, ms) and per upstream fetch (host and path, never query strings). |
No credentials are required to use the sources.
Source etiquette, caching, resilience
- robots.txt is checked per host and cached for 24 h; an unreachable robots.txt (5xx) means "disallow". Documented APIs (geo.admin.ch, Fedlex SPARQL, SNB, OpenHolidays, transport.opendata.ch, open-data portals) are called as APIs; website pages always go through the robots check.
- Requests identify the project in the User-Agent (standard crawler form, not a browser string) and
are paced per host (
SGM_MIN_INTERVAL). Identical requests already in flight share one upstream call. - Responses are cached on disk with per-source TTLs (timetables 1 min, weather and votes 10 min, pages and rates 6 h, waste 12 h, register 1 day, law and holidays 7 days) and pruned at startup.
- When a source is down, an expired copy up to
SGM_MAX_STALE_HOURSold is served instead of an error, and the result says so (data.stale_sources, with the retrieval date). Without a usable copy the result isstatus: "source_error"with the official link; no stack traces reach the model. - Every tool call has a time limit (
SGM_TOOL_TIMEOUT), so a chain of slow upstream calls ends in a clearsource_errorinstead of a client timeout. read_official_pageonly reads recognised Swiss government domains (admin.ch, ch.ch, 26 cantons, about 2,090 municipal websites, bodies with a legal mandate). Redirects are followed one hop at a time and each target is checked again (allowlist, robots.txt, and never a private or reserved address), so an official URL cannot lead to another site or into an internal network.
HTTP security
The stdio transport has no network exposure. For --transport http:
Shortened here. Read the whole README on GitHub.
Advanced
- Delivery
- swiss-grounding-mcp MCP server → your ahel connector (mcp.ahel.ai) → your AI.
- Catalog kind
- mcp-server
- Key
io-github-soheil1lotfi-swiss-grounding-mcp- Source
- github.com/Gastaan/swiss-grounding-mcp
- Hosted endpoint
https://swiss-grounding-mcp-542630986415.europe-west6.run.app/mcp