[πŸ‡©πŸ‡ͺ Deutsche Version](README.de.md) # πŸ›οΈ parlament-mcp [![CI](https://github.com/malkreide/parlament-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/malkreide/parlament-mcp/actions/workflows/ci.yml) [![PyPI version](https://badge.fury.io/py/parlament-mcp.svg)](https://badge.fury.io/py/parlament-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Swiss Public Data MCP Portfolio](https://img.shields.io/badge/Portfolio-Swiss%20Public%20Data%20MCP-blue)](https://github.com/malkreide) > **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)** – > connecting AI models to Swiss public data sources. > **Note:** This server covers the **federal** level (Curia Vista). This repo > additionally hosts a self-contained subproject under > [`openparldata-mcp/`](openparldata-mcp/README.md) β€” the **subnational** > counterpart for the 26 cantons and ~70 municipal parliaments > ([OpenParlData.ch](https://openparldata.ch)). The two are independent servers. An MCP server that connects AI models to the **Swiss Federal Parliament** via the [Curia Vista OData API](https://ws.parlament.ch/odata.svc/) (`ws.parlament.ch`). Access motions, interpellations, votes, members, sessions, and the **verbatim debate transcripts** of the Amtliches Bulletin – with **no API key required** (Phase 1 – No-Auth-First). --- ## 🎯 Anchor Demo Queries **Metadata layer:** > *"Welche VorstΓΆsse zu KI in der Schule sind hΓ€ngig?"* > β†’ `parlament_search_business(keyword="KI", keyword2="Schule", status="Eingereicht")` **Verbatim transcripts (Amtliches Bulletin):** > *"What did National Councillor Munz say in the 2024 spring session about the > Volksschule? Give me the exact wording with a correct AB citation."* > β†’ `parlament_search_transcripts(speaker_name="Munz", session_id=5202, keyword="Volksschule")` > β†’ then `parlament_get_transcript(transcript_id=…)` for the full wording. > > Returns short, **citable** excerpts (`AB 2024 N, 2024-03-13, Munz Martina`) with > a stable source URL; the verbatim text is fetched on demand, never in bulk. > > [β†’ More use cases by audience β†’](EXAMPLES.md) Perfect for the **KI-Fachgruppe Stadtverwaltung ZΓΌrich**: find pending motions on AI in education, or quote what was actually said in the chamber – instantly.

Demo: Claude queries pending AI motions via MCP tool call

--- ## πŸ”§ Tools | Tool | Description | |---|---| | `parlament_search_business` | Search VorstΓΆsse by keyword, type, status, council, date | | `parlament_get_business` | Full details of a single business (texts, FC response) | | `parlament_search_members` | Find councillors by canton (e.g. ZH), party, council | | `parlament_get_votes` | Parliamentary votes with Ja/Nein meaning | | `parlament_get_sessions` | List recent sessions with IDs for follow-up queries | | `parlament_search_transcripts` | Search debate transcripts β†’ **citable excerpts** with AB citation + source URL (speaker / session / business / date filters) | | `parlament_get_transcript` | Fetch the **verbatim full text** of a single speech by ID (capped, paginated, `is_excerpt` flagged) | --- ## πŸ—οΈ Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ MCP Host (Claude Desktop / β”‚ β”‚ Claude API / IDE) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ MCP Protocol (JSON-RPC 2.0) β”‚ Transport: stdio (local) / SSE (cloud) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ parlament-mcp β”‚ β”‚ FastMCP Β· Python Β· Pydantic v2 β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€ metadata layer (server.py) ──┐ β”‚ β”‚ search_business Β· get_business β”‚ β”‚ β”‚ search_members Β· get_votes β”‚ β”‚ β”‚ get_sessions β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€ transcript layer ────────────┐ ← separate module (transcripts.py) β”‚ β”‚ search_transcripts (excerpts) β”‚ Β· Language='DE' dedups editions β”‚ β”‚ get_transcript (verbatim) β”‚ Β· Type=1 = real speeches only β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Β· retry + 45s read timeout β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ HTTPS / OData v3 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ws.parlament.ch / odata.svc β”‚ β”‚ Curia Vista – No Auth Required β”‚ β”‚ β”‚ β”‚ Business Β· Vote Β· MemberCouncil β”‚ metadata path β”‚ Session ─< Meeting ─< Subject ─< Transcript transcript path β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## πŸš€ Installation ### Claude Desktop (stdio) Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "parlament": { "command": "uvx", "args": ["parlament-mcp"] } } } ``` ### Local development ```bash git clone https://github.com/malkreide/parlament-mcp cd parlament-mcp pip install -e . python -m parlament_mcp.server ``` ### Cloud / Railway (SSE) ```bash MCP_TRANSPORT=sse MCP_HOST=0.0.0.0 PORT=8080 python -m parlament_mcp.server # SSE endpoint: http://your-host:8080/sse ``` ### Network binding By default the server binds to `127.0.0.1` (localhost only). Set `MCP_HOST=0.0.0.0` **only** inside a container/cloud context (Docker, Railway, Render, Kubernetes). Never bind to `0.0.0.0` on a local dev machine – it exposes the server to your local network (NeighborJack risk); the server logs a warning if you do so outside a detected container. Transport is selected via `MCP_TRANSPORT` (`stdio` default, or `sse` / `streamable-http`); `--http` is kept as an alias for `streamable-http`. ### Authentication (optional) The HTTP transport is open by default (public read-only data). To require a bearer token, serve via the CORS/auth app factory and set `MCP_BEARER_TOKENS`: ```bash MCP_BEARER_TOKENS="alice:tok_abc,bob:tok_def" MCP_ALLOWED_ORIGINS="https://claude.ai" \ uvicorn parlament_mcp.server:create_http_app --factory --host 0.0.0.0 --port 8080 ``` Each request then needs `Authorization: Bearer `; identity comes from the validated token, not a session header (see [`docs/security.md`](docs/security.md)). ### Docker ```bash docker compose up --build # binds 127.0.0.1:8080 only # or build the hardened image directly (non-root, read-only FS): docker build -t parlament-mcp . ``` Kubernetes manifests (hardened `securityContext`, resource limits, egress `NetworkPolicy`, `Mcp-Session-Id` sticky routing) live in [`deploy/k8s/`](deploy/k8s/); an HAProxy stick-table example is in [`deploy/haproxy.cfg`](deploy/haproxy.cfg). --- ## πŸ”— Synergies | Partner Server | Combination | |---|---| | [`openparldata-mcp`](openparldata-mcp/README.md) | **Federal ↔ subnational** β€” same question across cantons & municipalities | | [`fedlex-mcp`](https://github.com/malkreide/fedlex-mcp) | Law text ↔ parliamentary debate that created it | | [`zurich-opendata-mcp`](https://github.com/malkreide/zurich-opendata-mcp) | City policy ↔ cantonal/federal motions | | [`swiss-statistics-mcp`](https://github.com/malkreide/swiss-statistics-mcp) | Data backing ↔ motions citing statistics | **Power query example:** ``` "Zeige mir alle ZΓΌrcher Motionen zu KI in der Bildung und verlinke die relevanten Bundesgesetze aus fedlex-mcp." ``` --- ## πŸ“Š Data Source - **API:** [ws.parlament.ch/odata.svc](https://ws.parlament.ch/odata.svc/) - **Authentication:** None (Phase 1 – No-Auth-First) - **Protocol:** OData v3 / JSON - **Coverage:** All parliamentary businesses since 1978; votes since ~2000. **Structured verbatim transcripts (Amtliches Bulletin) from 1999-12-06 onward** (earlier years 1891–1999 exist only as archive scans β€” see Known Limitations). - **Update cycle:** Real-time (official government data) ### Copyright β€” verbatim quotation is allowed Official proceedings of Swiss authorities are **excluded from copyright** under **Art. 5 para. 1 lit. a URG** (Swiss Copyright Act). The verbatim wording of parliamentary debates in the Amtliches Bulletin may therefore be reproduced and quoted freely β€” which is exactly what the transcript tools return: the wording itself, never a summary standing in for it. --- ## πŸ“œ Data sources & licenses | Source | License | Attribution | |---|---|---| | Curia Vista (ws.parlament.ch) | CC BY 4.0 | Β© Schweizer Parlament, CC BY 4.0 | Every tool returns a typed structured response (FastMCP exposes the output schema) carrying `source`, `license`, `provenance`, `match_type` and `count` alongside typed `results`. Data is passed through unmodified. ## 🧭 Phase This server is in **Phase 1 β€” Read-only Wrapper** (all tools `readOnlyHint: true`, no writes). The full phase model and transition criteria are in [`docs/roadmap.md`](docs/roadmap.md). ## πŸ”– MCP Protocol Version This server speaks **two protocol eras** over the same endpoint. The client's first request on a connection decides which one applies; a later claim from the other era is refused. | Era | Revision | Who reaches it | |---|---|---| | `initialize` handshake | `2024-11-05` … **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. | | Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. | Both revisions are pinned in [`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted against the installed SDK, so a Dependabot bump of `mcp` cannot move either one silently. This server builds no ASGI app to send an `initialize` through, so the gate asserts the SDK constants rather than a measured response β€” the weaker form, named rather than left unsaid. Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern** era, not for the handshake era β€” pinning against it alone would leave the era that current clients actually negotiate free to drift. **Update policy.** When the gate fails, do not edit the constant blindly: read the spec changelog between the two revisions, verify the server still behaves, then move the constant, this section, `README.de.md` and [`CHANGELOG.md`](CHANGELOG.md) together. ## 🧱 MCP primitives Phase 1 uses **Tools only** β€” rationale and the Phase-2 Resources plan are in [`docs/adr/ADR-003-mcp-primitives.md`](docs/adr/ADR-003-mcp-primitives.md). ## 🏷️ Tool annotations All tools declare explicit annotations consistent with their behaviour: | Tool | readOnly | destructive | idempotent | openWorld | |---|:--:|:--:|:--:|:--:| | `parlament_search_business` | βœ… | β€” | βœ… | βœ… | | `parlament_get_business` | βœ… | β€” | βœ… | βœ… | | `parlament_search_members` | βœ… | β€” | βœ… | βœ… | | `parlament_get_votes` | βœ… | β€” | βœ… | βœ… | | `parlament_get_sessions` | βœ… | β€” | βœ… | βœ… | | `parlament_search_transcripts` | βœ… | β€” | βœ… | βœ… | | `parlament_get_transcript` | βœ… | β€” | βœ… | βœ… | ## πŸ“ˆ Observability Structured JSON logs go to **stderr** (stdout stays reserved for the stdio protocol). OpenTelemetry tracing wraps each tool call and auto-instruments outgoing HTTP; set `OTEL_EXPORTER_OTLP_ENDPOINT` (with the `otel-export` extra) to ship spans. See [`docs/security.md`](docs/security.md) for the full security posture (Lethal-Trifecta assessment, egress allow-list, gateway hardening). --- ## πŸ›‘οΈ Safety & Limits | Aspect | Details | |--------|---------| | **Access** | Read-only (`readOnlyHint: true`) β€” the server cannot modify or delete any data | | **Personal data** | Parliamentary businesses are public record by law (BGΓ–). No private data is accessed or stored. | | **Rate limits** | Built-in per-query caps: max. 100 results (businesses/members), 50 (votes), 30 (transcript search), 10 (sessions). Transcript full text is capped per call and paginated β€” you never pull a whole session by accident. | | **Timeout** | 20 seconds per metadata call; 45 seconds for transcript reads (verbatim search is heavier) | | **Authentication** | No API keys required β€” Curia Vista is publicly accessible | | **Data source** | Official Swiss federal government data (Schweizerische Parlamentsdienste) | | **Terms of Service** | Subject to ToS of [ws.parlament.ch](https://ws.parlament.ch/) β€” Schweizerische Parlamentsdienste | --- ## Known Limitations **General** - OData `substringof()` filter is case-sensitive for some fields. - Session names may be `null` in the API for very recent sessions – use session ID. **Transcripts (Amtliches Bulletin)** β€” verified live 2026-07-19: - **Temporal coverage: from 1999-12-06 only.** The structured `Transcript` entity reaches back to Dec 1999. Debates from **1891–1999 exist only as scanned archive documents** (Bundesarchiv / Amtsdruckschriften) and are **not connected** here (no OCR in scope). A query whose whole date window predates coverage returns an explanatory error, not an empty result. - **No page number in the source.** The API carries no page/column field, so the classic `AB N ` form cannot be built. We emit a stable, verifiable substitute β€” `AB , , ` β€” plus the authoritative `source_url` (`SubjectId`) and the `transcript_id`. This is a documented, honest trade-off, not an omission. - **Language behaviour (important).** `Language` is the *edition*, not the spoken language. The tools filter `Language eq 'DE'` purely to **deduplicate** the three byte-identical editions (DE/FR/IT) down to one copy. Every speech is returned in its **original wording**; a French or Italian speech is **not** hidden β€” its real language is reported in the `language` field (`de`/`fr`/`it`). The response states this via `language_note`. - **Truncation is explicit, never silent.** Search returns short excerpts (`snippet`, ~320 chars) with `is_excerpt`, `total_length_chars` and a hint to fetch the full text. `parlament_get_transcript` caps output at `max_chars`; when it truncates it sets `is_excerpt=True` and returns a `next_offset` to continue. - **Verbatim only, never summarised.** The wording *is* the product; the tools never substitute a summary for the actual text. - **Latency:** a free-text `keyword` combined with a `speaker_name` is the slowest path (~40 s). Add a `session_id`, `business_number` or date window to keep reads around 1–2 s. --- ## πŸ§ͺ Testing ```bash pip install -e ".[dev]" # Unit + mocked integration tests (no network), as run in CI: PYTHONPATH=src pytest tests/ -m "not live" # Include live tests against the real ws.parlament.ch API: PYTHONPATH=src pytest tests/ -m live ``` HTTP is mocked with `respx`; network-dependent tests are marked `@pytest.mark.live` and excluded from CI via `-m "not live"`. Tool definitions are pinned in `tool-hashes.json` (`python -m parlament_mcp.tool_hashes --check`). --- ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). --- ## Security See [SECURITY.md](SECURITY.md) for the security policy and posture (vulnerability reporting, Lethal-Trifecta assessment, accepted risks). --- ## License MIT Β© [Hayal Oezkan](https://github.com/malkreide) β€” see [LICENSE](LICENSE) --- ## Author **Hayal Oezkan** Β· [github.com/malkreide](https://github.com/malkreide) ## Installation Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β€” no clone or manual install needed. Add to your MCP client config (`mcpServers` for Claude Desktop, Cursor and Windsurf; use a top-level `servers` key for VS Code in `.vscode/mcp.json`): ```json { "mcpServers": { "parlament-mcp": { "command": "uvx", "args": [ "parlament-mcp" ] } } } ```