Part of the Swiss Public Data MCP Portfolio β open-source MCP servers connecting AI agents to Swiss public and open data.
This is a private project. It is independent of any employer or institutional affiliation and represents no official position of any authority.
swiss-procurement-mcp

MCP server for Swiss public procurement β read access to the official simap.ch API, covering all cantons and the Confederation, updated intraday.
π― Anchor demo query
Β«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?Β»
A single search_procurements_detailed(query="Schulhaus", canton="ZH", published_from="2026-01-01")
returns the leading tenders already expanded with their BKP construction codes and
procuring offices β connecting procurement to school-building planning in one call
(optionally paired with search_construction_codes to resolve a category).
Demo

Why this server exists
Swiss public procurement is published on simap.ch. The platform's web UI is
searchable by hand, but the amtsblatt-mcp
server only reaches the three cantons (AR, BS, TI) that still mirror tenders to
the Amtsblattportal β Zurich among the missing.
simap closes that gap: it operates a documented OpenAPI 3 read API (v1.5.1)
whose search and detail endpoints are marked security: None and are callable
without authentication. This server wraps exactly those read endpoints.
Mnemonic: The web UI is the front door; the API is the loading dock. Probe the dock.
Architecture decision
Architecture A (live API only, short-lived cache).
- The public search, detail and reference endpoints are unauthenticated and were
confirmed working live (2026-07-26).
- Publications change intraday, so the cache TTL is deliberately short (30 min).
- The ~200 write /
my/ / OIDC-protected endpoints (publishing tenders,
submitting offers) are out of scope β this server never writes.
Every response carries source and provenance (live_api / cached /
degraded). Upstream failure yields a degraded envelope, never a silent empty
list.
Live-probe findings (2026-07-26)
| Endpoint | Auth | Result |
|---|
/publications/v2/project/project-search | none | 20 hits, canton filter, current-day |
/publications/v1/.../publication-details/... | none | full record: criteria, deadlines, codes |
/publications/v1/publication/{id}/past-publications | none | project lifecycle |
/codes/v1/cpv/search | none | CPV full-text search |
/codes/v1/{bkp,npk,ebkp-h,ebkp-t,oag,cpc}/search | none | Swiss construction codes |
/procoffices/v1/po/public | none | ~1 MB office list (client-side filter) |
/cantons/v1, /countries/v1 | none | reference data |
Known findings
- Wrong host, wrong conclusion. The read API lives under
www.simap.ch/api.
The simap.ch/de web UI is a separate SSR app that exposes none of it β
probing the UI produced an earlier, mistaken "no API" verdict.
lang is mandatory on project-search. Omitting it is HTTP 400
(errorCode E0025), not an empty result. The client injects a default.
- Award is not "award".
newestPubTypes=award returns HTTP 400. Awards are
split by procedure: award_tender, award_study_contract,
award_competition, direct_award. The search_awards tool queries all four.
- Canton ids are bare.
ZH, not CH-ZH. Passing an ISO subdivision code
silently matches nothing; this server rejects it with a clear error.
- A session cookie is required. The first request sets it; a persistent
HTTP client handles this transparently.
| Tool | Purpose |
|---|
search_procurements | Search projects by canton, CPV, process type, date, text |
search_procurements_detailed | Search + full detail for the top n hits in one call (aggregated) |
search_awards | Awarded contracts only (all four award types at once) |
get_procurement_details | Full record for one publication |
get_publication_history | Earlier publications of the same project (tender β award) |
search_cpv_codes | Resolve keywords to CPV classification codes |
search_construction_codes | Swiss construction codes (BKP, NPK, eBKP, OAG, CPC) |
find_procurement_office | Public procurement offices by partial name |
source_status | Reachability and latency of the simap.ch API |
All tools carry readOnlyHint, idempotentHint and openWorldHint (they query
the live simap.ch API).
Every tool takes a single validated argument object. Bounds, allow-lists and
patterns are declared on the input models in
inputs.py β so an out-of-range limit or
an unknown canton is rejected before any upstream request, and the constraints
are visible to the model in the tool schema rather than buried in the tool body:
search_procurements({"canton": "ZH", "query": "Schulhaus", "limit": 20})
The models set strict=True (no silent "10" β 10 coercion) and
extra="forbid" (unknown fields are rejected, not ignored). The canton, process
type, publication type, code system and language allow-lists are derived from
constants.py, so they cannot drift from the probe-verified tables.
What canton= means
simap offers exactly one geographic filter, orderAddressCantons, and it selects
by where the work is delivered β not by who is procuring. When a procuring
office files a free-text address, the structured canton is null and the
publication is invisible to that filter. Measured CH-wide over 500 projects
published since 2026-07-01: 303 (60.6%) carry no canton, among them the Amt
fΓΌr Hochbauten ZΓΌrich, GrΓΌn Stadt ZΓΌrich, USZ, BBL and SBB.
canton_match therefore makes the question explicit:
| Value | Matches | Zurich, 2026-07-01β¦27 |
|---|
procuring_body (default) | procured by that canton's public bodies, incl. communal and subordinate offices (issuedByOrganizations) | 410 projects |
place_of_delivery | the work is delivered there (orderAddressCantons) | 263 projects |
both | union of the two; two upstream calls, no pagination | 441 projects |
The 31 projects only place_of_delivery finds are federal bodies procuring in
Zurich (ETH, Empa, Flughafen ZΓΌrich AG) β a different question, not a gap, which
is why this is three explicit semantics rather than a silent union.
Every response states in note which semantics were applied.
Portfolio connections
- A vendor's UID links to
register-mcp.
- BKP / eBKP construction codes on a tender connect procurement to school-building
planning and to
zh-education-mcp.
- Complements
amtsblatt-mcp with
national coverage instead of three cantons.
Installation
uvx swiss-procurement-mcp
Claude Desktop
{
"mcpServers": {
"swiss-procurement": {
"command": "uvx",
"args": ["swiss-procurement-mcp"]
}
}
}
Cloud (Render / Railway)
MCP_TRANSPORT=sse HOST=0.0.0.0 PORT=8000 python -m swiss_procurement_mcp
Container
docker compose up --build
The image is multi-stage and runs as a non-root system user. compose.yaml
adds a read-only root filesystem, drops all capabilities, sets
no-new-privileges, and caps memory, CPU and PIDs. No secret is needed at
runtime β the wrapped simap.ch endpoints are public.
CI builds the image on every push and asserts both properties that matter:
that the container does not run as uid 0, and that the server still imports
under --read-only --cap-drop ALL.
Configuration
| Variable | Default | Purpose |
|---|
MCP_TRANSPORT | stdio | stdio | sse | streamable-http |
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. |
PORT / MCP_PORT | 8000 | HTTP port (cloud transports only) |
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. |
No API keys β the wrapped simap.ch read endpoints are fully public.
Each tool call emits one tool_call record with its name, status and latency;
upstream failures add a upstream_degraded record at WARNING. Neither carries
the exception message or any upstream response body.
{"ts":"2026-07-27T15:50:25","level":"INFO","logger":"swiss_procurement_mcp","msg":"tool_call","tool":"search_procurements","status":"ok","latency_ms":312}
Testing
PYTHONPATH=src pytest tests/ -m "not live"
PYTHONPATH=src pytest tests/ -m live
See EXAMPLES.md for use cases grouped by audience (schools,
public, administration, developers) and a tool-selection reference table.
Known limitations
- Projects, not publications.
project-search indexes projects and
represents each by its newest publication. A project tendered in March and
awarded in July appears once, as the July award; search_awards likewise only
finds projects whose newest publication is an award, so a later correction
hides it. get_publication_history reaches the earlier publications.
- At least one filter is required. simap answers a filterless query with
nothing rather than everything, so the tools refuse it with that reason
instead of reporting an empty result.
- Read-only by design. Publishing and submission endpoints exist in the
simap API but are deliberately not wrapped.
- Award coverage is uneven across cantons; some publish awards diligently,
others rarely. Absence of an award is not proof none happened.
- No contract values in search results. Amounts, where published, live in the
detail record's statistics section, which varies by procedure.
- Unofficial client. Publications remain authoritative on simap.ch itself.
Project structure
swiss-procurement-mcp/
βββ src/swiss_procurement_mcp/
β βββ server.py # FastMCP tools (9, read-only)
β βββ client.py # simap.ch HTTP client + retry + normalisation
β βββ constants.py # probe-derived lookup tables (cantons, pub types, codes)
β βββ models.py # Pydantic v2 envelopes (source + provenance)
β βββ _log.py # structured JSON logging to stderr + @logged_tool
β βββ inputs.py # strict Pydantic tool-input models (bounds, allow-lists)
β βββ __main__.py # Dual-transport entry point (stdio / SSE / streamable-http)
βββ tests/ # respx-mocked + @pytest.mark.live
βββ .github/workflows/ # CI + OIDC PyPI/MCP-registry publish
Maturity & updates
Phase 1 β read-only. This server wraps only the public read endpoints; the
write / OIDC-protected simap endpoints are deliberately out of scope. See the
SECURITY.md re-evaluation triggers for the conditions that would
move it to a write phase.
The server targets the MCP protocol version negotiated by the pinned mcp SDK.
SDK and dependency updates arrive as Dependabot PRs, so
a breaking protocol or SDK change is reviewed deliberately rather than drifting
in silently.
Contributing
Contributions are welcome β see CONTRIBUTING.md for how to
report bugs, suggest a new endpoint, or submit code.
Security
This is a read-only, no-PII, public-open-data server. Audited against the
portfolio MCP best-practice catalogue (15 pass / 16 partial / 1 fail across
32 applicable checks, production-ready). See SECURITY.md for the
posture and how to report a vulnerability, and audits/ for the full
report.
License
MIT License β see LICENSE. The tenders are official public-procurement
announcements; simap.ch publishes no explicit open-data licence, so reuse follows
the simap.ch terms (see Credits).
Author
Hayal Oezkan Β· github.com/malkreide
Changelog
See CHANGELOG.md.
Credits
- Data: simap.ch read API v1.5.1, operated by the simap.ch association. API docs: simap.ch/api-doc β machine-readable OpenAPI spec at
/api/specifications/simap.yaml, which a live test checks the enum constants against. Guides: kissimap.ch.
- 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. Attribute the source as simap.ch (Verein simap.ch).
- Built following the
mcp-data-source-probe methodology.
The code in this repository is MIT licensed; the data is simap.ch's, under its terms (see above). Public money, public code.