{
  "markdown": "<!-- mcp-name: io.github.malkreide/swiss-procurement-mcp -->\n\n> **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)** — open-source MCP servers connecting AI agents to Swiss public and open data.\n>\n> This is a **private project**. It is independent of any employer or institutional affiliation and represents no official position of any authority.\n\n# swiss-procurement-mcp\n\n[![CI](https://github.com/malkreide/swiss-procurement-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/malkreide/swiss-procurement-mcp/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/swiss-procurement-mcp)](https://pypi.org/project/swiss-procurement-mcp/)\n[![Python](https://img.shields.io/pypi/pyversions/swiss-procurement-mcp)](https://pypi.org/project/swiss-procurement-mcp/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-orange.svg)](https://modelcontextprotocol.io/)\n[![Portfolio](https://img.shields.io/badge/portfolio-swiss--public--data--mcp-blue)](https://github.com/malkreide/swiss-public-data-mcp)\n[![Deutsch](https://img.shields.io/badge/Doku-Deutsch-red.svg)](README.de.md)\n\nMCP server for **Swiss public procurement** — read access to the official simap.ch API, covering all cantons and the Confederation, updated intraday.\n\n---\n\n## 🎯 Anchor demo query\n\n> *«Which school-building tenders did the City of Zurich publish in 2026, which BKP construction categories do they concern, and who are the procuring offices?»*\n\nA single `search_procurements_detailed(query=\"Schulhaus\", canton=\"ZH\", published_from=\"2026-01-01\")`\nreturns the leading tenders already expanded with their BKP construction codes and\nprocuring offices — connecting procurement to school-building planning in one call\n(optionally paired with `search_construction_codes` to resolve a category).\n\n### Demo\n\n![Demo: Claude using search_procurements_detailed and search_construction_codes](docs/assets/demo.svg)\n\n---\n\n## Why this server exists\n\nSwiss public procurement is published on simap.ch. The platform's web UI is\nsearchable by hand, but the [`amtsblatt-mcp`](https://github.com/malkreide/amtsblatt-mcp)\nserver only reaches the three cantons (AR, BS, TI) that still mirror tenders to\nthe Amtsblattportal — Zurich among the missing.\n\nsimap closes that gap: it operates a documented **OpenAPI 3 read API (v1.5.1)**\nwhose search and detail endpoints are marked `security: None` and are callable\n**without authentication**. This server wraps exactly those read endpoints.\n\n> **Mnemonic:** *The web UI is the front door; the API is the loading dock. Probe the dock.*\n\n---\n\n## Architecture decision\n\n**Architecture A (live API only, short-lived cache).**\n\n- The public search, detail and reference endpoints are unauthenticated and were\n  confirmed working live (2026-07-26).\n- Publications change intraday, so the cache TTL is deliberately short (30 min).\n- The ~200 write / `my/` / OIDC-protected endpoints (publishing tenders,\n  submitting offers) are **out of scope** — this server never writes.\n\nEvery response carries `source` and `provenance` (`live_api` / `cached` /\n`degraded`). Upstream failure yields a `degraded` envelope, never a silent empty\nlist.\n\n---\n\n## Live-probe findings (2026-07-26)\n\n| Endpoint | Auth | Result |\n|---|---|---|\n| `/publications/v2/project/project-search` | none | 20 hits, canton filter, current-day |\n| `/publications/v1/.../publication-details/...` | none | full record: criteria, deadlines, codes |\n| `/publications/v1/publication/{id}/past-publications` | none | project lifecycle |\n| `/codes/v1/cpv/search` | none | CPV full-text search |\n| `/codes/v1/{bkp,npk,ebkp-h,ebkp-t,oag,cpc}/search` | none | Swiss construction codes |\n| `/procoffices/v1/po/public` | none | ~1 MB office list (client-side filter) |\n| `/cantons/v1`, `/countries/v1` | none | reference data |\n\n### Known findings\n\n1. **Wrong host, wrong conclusion.** The read API lives under `www.simap.ch/api`.\n   The `simap.ch/de` web UI is a separate SSR app that exposes none of it —\n   probing the UI produced an earlier, mistaken \"no API\" verdict.\n2. **`lang` is mandatory** on project-search. Omitting it is HTTP 400\n   (errorCode `E0025`), not an empty result. The client injects a default.\n3. **Award is not \"award\".** `newestPubTypes=award` returns HTTP 400. Awards are\n   split by procedure: `award_tender`, `award_study_contract`,\n   `award_competition`, `direct_award`. The `search_awards` tool queries all four.\n4. **Canton ids are bare.** `ZH`, not `CH-ZH`. Passing an ISO subdivision code\n   silently matches nothing; this server rejects it with a clear error.\n5. **A session cookie is required.** The first request sets it; a persistent\n   HTTP client handles this transparently.\n\n---\n\n## Tools\n\n| Tool | Purpose |\n|---|---|\n| `search_procurements` | Search projects by canton, CPV, process type, date, text |\n| `search_procurements_detailed` | Search + full detail for the top *n* hits in one call (aggregated) |\n| `search_awards` | Awarded contracts only (all four award types at once) |\n| `get_procurement_details` | Full record for one publication |\n| `get_publication_history` | Earlier publications of the same project (tender → award) |\n| `search_cpv_codes` | Resolve keywords to CPV classification codes |\n| `search_construction_codes` | Swiss construction codes (BKP, NPK, eBKP, OAG, CPC) |\n| `find_procurement_office` | Public procurement offices by partial name |\n| `source_status` | Reachability and latency of the simap.ch API |\n\nAll tools carry `readOnlyHint`, `idempotentHint` and `openWorldHint` (they query\nthe live simap.ch API).\n\nEvery tool takes a single validated argument object. Bounds, allow-lists and\npatterns are declared on the input models in\n[`inputs.py`](src/swiss_procurement_mcp/inputs.py) — so an out-of-range limit or\nan unknown canton is rejected before any upstream request, and the constraints\nare visible to the model in the tool schema rather than buried in the tool body:\n\n```python\nsearch_procurements({\"canton\": \"ZH\", \"query\": \"Schulhaus\", \"limit\": 20})\n```\n\nThe models set `strict=True` (no silent `\"10\"` → `10` coercion) and\n`extra=\"forbid\"` (unknown fields are rejected, not ignored). The canton, process\ntype, publication type, code system and language allow-lists are derived from\n`constants.py`, so they cannot drift from the probe-verified tables.\n\n### What `canton=` means\n\nsimap offers exactly one geographic filter, `orderAddressCantons`, and it selects\nby **where the work is delivered** — not by who is procuring. When a procuring\noffice files a free-text address, the structured canton is `null` and the\npublication is invisible to that filter. Measured CH-wide over 500 projects\npublished since 2026-07-01: **303 (60.6%) carry no canton**, among them the Amt\nfür Hochbauten Zürich, Grün Stadt Zürich, USZ, BBL and SBB.\n\n`canton_match` therefore makes the question explicit:\n\n| Value | Matches | Zurich, 2026-07-01…27 |\n|---|---|---|\n| `procuring_body` *(default)* | procured by that canton's public bodies, incl. communal and subordinate offices (`issuedByOrganizations`) | **410** projects |\n| `place_of_delivery` | the work is delivered there (`orderAddressCantons`) | 263 projects |\n| `both` | union of the two; two upstream calls, no pagination | 441 projects |\n\nThe 31 projects only `place_of_delivery` finds are federal bodies procuring in\nZurich (ETH, Empa, Flughafen Zürich AG) — a different question, not a gap, which\nis why this is three explicit semantics rather than a silent union.\n\nEvery response states in `note` which semantics were applied.\n\n---\n\n## Portfolio connections\n\n- A vendor's UID links to [`register-mcp`](https://github.com/malkreide/register-mcp).\n- BKP / eBKP construction codes on a tender connect procurement to school-building\n  planning and to [`zh-education-mcp`](https://github.com/malkreide/zh-education-mcp).\n- Complements [`amtsblatt-mcp`](https://github.com/malkreide/amtsblatt-mcp) with\n  national coverage instead of three cantons.\n\n---\n\n## Installation\n\n```bash\nuvx swiss-procurement-mcp\n```\n\n### Claude Desktop\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-procurement\": {\n      \"command\": \"uvx\",\n      \"args\": [\"swiss-procurement-mcp\"]\n    }\n  }\n}\n```\n\n### Cloud (Render / Railway)\n\n```bash\nMCP_TRANSPORT=sse HOST=0.0.0.0 PORT=8000 python -m swiss_procurement_mcp\n```\n\n### Container\n\n```bash\ndocker compose up --build        # SSE on :8000\n```\n\nThe image is multi-stage and runs as a non-root system user. `compose.yaml`\nadds a read-only root filesystem, drops all capabilities, sets\n`no-new-privileges`, and caps memory, CPU and PIDs. No secret is needed at\nruntime — the wrapped simap.ch endpoints are public.\n\nCI builds the image on every push and asserts both properties that matter:\nthat the container does not run as uid 0, and that the server still imports\nunder `--read-only --cap-drop ALL`.\n\n### Configuration\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `MCP_TRANSPORT` | `stdio` | `stdio` \\| `sse` \\| `streamable-http` |\n| `MCP_HOST` / `HOST` | `127.0.0.1` | HTTP binding (cloud transports only). Defaults to loopback; set `0.0.0.0` explicitly to expose all interfaces in a cloud deployment. |\n| `MCP_CORS_ORIGINS` | _(unset)_ | Comma-separated origins allowed to call the HTTP transports from a browser. Unset means no cross-origin browser access at all — stdio and non-browser clients are unaffected. `Mcp-Session-Id` is exposed and accepted for the listed origins, so a browser client can hold a session. `*` is honoured but logs a warning and disables credentials, because browsers reject a wildcard origin together with credentials. |\n| `MCP_STATELESS` | _(off)_ | Set to `1` to run the streamable-http transport with no session tracking. Removes session affinity as a concern for multi-instance deployments; gives up SSE stream resumption and server-initiated notifications. No effect on the legacy SSE transport, which logs a warning if asked. See [docs/load-balancing.md](docs/load-balancing.md). |\n| `PORT` / `MCP_PORT` | `8000` | HTTP port (cloud transports only) |\n| `LOG_LEVEL` | `INFO` | `DEBUG` \\| `INFO` \\| `WARNING` \\| `ERROR`. Structured JSON, one object per line, always on **stderr** — stdout carries the MCP protocol on a stdio transport. |\n\nNo API keys — the wrapped simap.ch read endpoints are fully public.\n\nBuilt on [structlog](https://www.structlog.org/). Every event emitted during a\ntool call carries that call's `correlation_id`, bound via `contextvars` — so a\nfailure logged deep inside the HTTP client can be joined to the request that\ncaused it without threading context through every function.\n\n| Level | Emitted when |\n|---|---|\n| `DEBUG` | a tool call was entered (`tool_call_started`) — tells you whether a hung call ever started |\n| `INFO` | a tool call finished cleanly, with latency |\n| `WARNING` | simap.ch was unreachable or errored (`upstream_degraded`) |\n| `ERROR` | a tool call raised |\n\nRecords carry the exception *type* only — never its message and never an\nupstream response body (OBS-002).\n\n```json\n{\"event\":\"tool_call_started\",\"tool\":\"search_procurements\",\"correlation_id\":\"23221af26ae640c7\",\"level\":\"debug\",\"timestamp\":\"2026-07-27T22:20:07.494276Z\"}\n{\"status\":\"ok\",\"latency_ms\":312,\"event\":\"tool_call\",\"tool\":\"search_procurements\",\"correlation_id\":\"23221af26ae640c7\",\"level\":\"info\",\"timestamp\":\"2026-07-27T22:20:07.806Z\"}\n```\n\n---\n\n## MCP Protocol Version\n\n| | |\n|---|---|\n| **Served via the `initialize` handshake** | `2024-11-05` … **`2025-11-25`** — the handshake ceiling |\n| **Served via the per-request envelope** | **`2026-07-28`** |\n| **Who picks** | The client's first request, once per connection. A request carrying the `2026-07-28` `_meta` envelope opens a modern connection; anything else opens a handshake connection. |\n| **Pinned in** | `MCP_PROTOCOL_VERSION` in [`server.py`](src/swiss_procurement_mcp/server.py) |\n| **SDK** | `mcp>=2.0.0,<3` |\n| **Cache hints** | `tools/list` and `server/discover`: `ttlMs` 300000, `cacheScope` `public` |\n\nThe MCP Python SDK negotiates the protocol version in the session layer and\noffers no constructor parameter for it, so the version cannot be pinned by\nconfiguration. It is pinned as a declared constant and enforced by detection:\n\n- **At runtime**, a mismatch between the constant and the SDK logs a\n  `protocol_version_drift` event at `WARNING`. The server keeps working.\n- **In CI**, `tests/test_protocol_version.py` fails.\n\nThat split is deliberate. An SDK bump should break *our* build, not the runtime\nof someone who upgraded `mcp` in their own environment.\n\n### Update policy\n\n- Dependabot opens SDK update PRs monthly (`.github/dependabot.yml`).\n- When an SDK update moves the protocol version, the CI test fails. The fix is\n  **not** to edit the constant blindly: read the spec changelog for what changed\n  between the two versions, verify the server still behaves, then bump the\n  constant, this section and `CHANGELOG.md` in one commit.\n- Protocol-version bumps are called out explicitly in `CHANGELOG.md`, not folded\n  into a dependency-bump line.\n\n---\n\n## Primitives: tools only\n\nThis server exposes **tools** and neither resources nor prompts. That is a\ndecision, not an omission, so here is the reasoning (ARCH-008).\n\n**Why not resources.** Resources address *identifiable, listable* content —\n`GET`-like reads the client can enumerate and cache. simap's endpoints are the\nopposite: every useful call is a query with filters over a corpus of ~200k\npublications that changes intraday. A resource URI would either enumerate\nsomething unbounded or encode a full query in the URI, which is a tool with\nextra steps.\n\nTwo tools were checked concretely for migration potential and rejected for\nspecific reasons, not by blanket policy:\n\n| Candidate | Why it stays a tool |\n|---|---|\n| `source_status` | Genuinely resource-shaped — one fixed, cacheable document. But it exists to be *called* when a result looks wrong, and a resource the model has to remember to re-read is worse at that job than a tool it can invoke on suspicion. |\n| `search_cpv_codes` | The CPV catalogue is finite and stable enough to enumerate. But it is ~10k entries; exposing it as a resource would push the whole classification into the context window, when the point of the tool is that the *server* does the lookup. |\n\n**Why not prompts.** A curated prompt list would encode question templates\n(\"which tenders in canton X…\"). The tool docstrings already carry that guidance\nwhere the model actually reads it, and prompts would duplicate it in a second\nplace that can drift — this repo has already been bitten twice by exactly that\nclass of duplication.\n\nThis will be revisited if the server ever gains a genuinely enumerable,\nslow-changing dataset.\n\n---\n\n## Testing\n\n```bash\nPYTHONPATH=src pytest tests/ -m \"not live\"   # offline, respx-mocked\nPYTHONPATH=src pytest tests/ -m live         # hits the real API\n```\n\nSee [EXAMPLES.md](EXAMPLES.md) for use cases grouped by audience (schools,\npublic, administration, developers) and a tool-selection reference table.\n\n---\n\n## Known limitations\n\n- **Projects, not publications.** `project-search` indexes projects and\n  represents each by its *newest* publication. A project tendered in March and\n  awarded in July appears once, as the July award; `search_awards` likewise only\n  finds projects whose newest publication is an award, so a later correction\n  hides it. `get_publication_history` reaches the earlier publications.\n- **Lot-based procurements are traced per lot.** Upstream keeps the publication\n  history per lot, so `get_publication_history` needs a `lot_id` whenever the\n  search result shows `lots_type: \"with\"` — take one from that result's `lots`\n  list. Without it the source answers HTTP 400 and the tool reports a degraded\n  response naming the missing parameter. Measured 2026-08-29 over 80\n  publications: all 4 with lots behaved this way, all 76 without lots answered\n  directly.\n- **At least one filter is required.** simap answers a filterless query with\n  nothing rather than everything, so the tools refuse it with that reason\n  instead of reporting an empty result.\n- **Read-only by design.** Publishing and submission endpoints exist in the\n  simap API but are deliberately not wrapped.\n- **Award coverage is uneven** across cantons; some publish awards diligently,\n  others rarely. Absence of an award is not proof none happened.\n- **No contract values in search results.** Amounts, where published, live in the\n  detail record's statistics section, which varies by procedure.\n- **Unofficial client.** Publications remain authoritative on simap.ch itself.\n\n---\n\n## Project structure\n\n```\nswiss-procurement-mcp/\n├── src/swiss_procurement_mcp/\n│   ├── server.py      # MCPServer tools (9, read-only)\n│   ├── client.py      # simap.ch HTTP client + retry + normalisation\n│   ├── constants.py   # probe-derived lookup tables (cantons, pub types, codes)\n│   ├── models.py      # Pydantic v2 envelopes (source + provenance)\n│   ├── inputs.py      # strict Pydantic tool-input models (bounds, allow-lists)\n│   ├── _fuzzy.py      # term widening for the taxonomy lookups (ARCH-003)\n│   ├── _log.py        # structured JSON logging to stderr + @logged_tool\n│   ├── _net.py        # DNS-pinned transport (egress allow-list)\n│   ├── _cors.py       # CORS layer for the HTTP transports\n│   └── __main__.py    # Dual-transport entry point (stdio / SSE / streamable-http)\n├── tests/             # respx-mocked + @pytest.mark.live\n└── .github/workflows/ # CI + OIDC PyPI/MCP-registry publish\n```\n\n### Why there is no `tools/` package\n\nThe portfolio structure standard asks for a `tools/` package once a server\nexposes more than five tools. This one exposes nine and keeps them in\n`server.py`, which is a deliberate deviation rather than an oversight — recorded\nhere because this is where the standard, and anyone comparing against it, looks.\n\n`server.py` is ~900 lines and the surrounding modules above are already split out\nby concern, so the intent of the standard — a codebase navigable without\nscrolling one omnibus file — is met. What the split would add is the literal file\nlayout.\n\nThe companion server `amtsblatt-mcp` is the case where it was worth doing: its\n`server.py` had grown to 2477 lines holding HTTP plumbing, XML parsing, a\ntaxonomy cache, the input models and every handler, and it was split in that\nproject's 0.21.0. That refactor is also the reason for caution here — it\nintroduced a defect (an extracted module captured a cache global by value, so a\ntool silently reported stale state) that the **entire test suite passed\nthrough**, because no test covered the affected path. It was caught by reading\nthe diff.\n\nMoving nine handlers for the literal form of a standard whose intent is already\nsatisfied would take that risk for no navigational gain. This should be revisited\nif `server.py` passes roughly 1500 lines.\n\n---\n\n## Maturity & updates\n\n**Phase 1 — read-only** (see [ROADMAP.md](ROADMAP.md) for the phase-specific\nbacklog and what a phase transition would require). This server wraps only the\npublic read endpoints; the\nwrite / OIDC-protected simap endpoints are deliberately out of scope. See the\n[SECURITY.md](SECURITY.md) re-evaluation triggers for the conditions that would\nmove it to a write phase.\n\nThe server targets the MCP spec version pinned as `MCP_PROTOCOL_VERSION` — see\n[MCP Protocol Version](#mcp-protocol-version) above for the current value and\nhow the pin is enforced. SDK and dependency updates arrive as\n[Dependabot](.github/dependabot.yml) PRs, so a breaking protocol or SDK change\nis reviewed deliberately rather than drifting in silently.\n\n## Contributing\n\nContributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for how to\nreport bugs, suggest a new endpoint, or submit code.\n\n## Security\n\nThis is a read-only, no-PII, public-open-data server. Audited against the\nportfolio MCP best-practice catalogue (**15 pass / 16 partial / 1 fail** across\n32 applicable checks, production-ready). See [SECURITY.md](SECURITY.md) for the\nposture and how to report a vulnerability, and [`audits/`](audits/) for the full\nreport.\n\n## License\n\nMIT License — see [LICENSE](LICENSE). The tenders are official public-procurement\nannouncements; simap.ch publishes no explicit open-data licence, so reuse follows\nthe simap.ch terms (see Credits).\n\n## Author\n\n**Hayal Oezkan** · [github.com/malkreide](https://github.com/malkreide)\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md).\n\n---\n\n## Credits\n\n- Data: [simap.ch](https://www.simap.ch) read API v1.5.1, operated by the simap.ch association. API docs: [simap.ch/api-doc](https://www.simap.ch/api-doc) — machine-readable OpenAPI spec at [`/api/specifications/simap.yaml`](https://www.simap.ch/api/specifications/simap.yaml), which a live test checks the enum constants against. Guides: [kissimap.ch](https://www.kissimap.ch/de/anleitungen).\n- The underlying tenders are official public-procurement announcements by Swiss public bodies. simap.ch publishes **no explicit open-data licence**; reuse is subject to the [simap.ch terms](https://www.simap.ch/de/about/legal). Attribute the source as *simap.ch (Verein simap.ch)*.\n- Built following the `mcp-data-source-probe` methodology.\n\nThe **code** in this repository is MIT licensed; the **data** is simap.ch's, under its terms (see above). Public money, public code.\n",
  "bytes": 21413,
  "sha": "17b10a843e81de93a695842b0a7a2c0622e70d4c9c18dfc9ead2c6e75d0053d0",
  "repo_slug": "malkreide/swiss-procurement-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_swiss_procurement_mc_6cbbd0aa/readme"
}