Skip to main content
ClaudeWave
malkreide avatar
malkreide

swiss-procurement-mcp

View on GitHub

MCP server for the simap.ch Swiss public procurement API — all cantons, read-only

MCP ServersOfficial Registry1 stars0 forksPythonMITUpdated today
Install in Claude Code / Claude Desktop
Method: UVX (Python) · swiss-procurement-mcp
Claude Code CLI
claude mcp add swiss-procurement-mcp -- uvx swiss-procurement-mcp
claude_desktop_config.json (Claude Desktop)
{
  "mcpServers": {
    "swiss-procurement-mcp": {
      "command": "uvx",
      "args": ["swiss-procurement-mcp"]
    }
  }
}
1. Run the command above in your terminal (Claude Code), or paste the JSON config into claude_desktop_config.json (Claude Desktop).
2. Replace any <placeholder> values with your API keys or paths.
3. Restart Claude. The MCP server and its tools appear automatically.
Use cases

MCP Servers overview

<!-- mcp-name: io.github.malkreide/swiss-procurement-mcp -->

> **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.
>
> 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

[![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)
[![PyPI](https://img.shields.io/pypi/v/swiss-procurement-mcp)](https://pypi.org/project/swiss-procurement-mcp/)
[![Python](https://img.shields.io/pypi/pyversions/swiss-procurement-mcp)](https://pypi.org/project/swiss-procurement-mcp/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-orange.svg)](https://modelcontextprotocol.io/)
[![Portfolio](https://img.shields.io/badge/portfolio-swiss--public--data--mcp-blue)](https://github.com/malkreide/swiss-public-data-mcp)
[![Deutsch](https://img.shields.io/badge/Doku-Deutsch-red.svg)](README.de.md)

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

![Demo: Claude using search_procurements_detailed and search_construction_codes](docs/assets/demo.svg)

---

## 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`](https://github.com/malkreide/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

1. **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.
2. **`lang` is mandatory** on project-search. Omitting it is HTTP 400
   (errorCode `E0025`), not an empty result. The client injects a default.
3. **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.
4. **Canton ids are bare.** `ZH`, not `CH-ZH`. Passing an ISO subdivision code
   silently matches nothing; this server rejects it with a clear error.
5. **A session cookie is required.** The first request sets it; a persistent
   HTTP client handles this transparently.

---

## Tools

| 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`](src/swiss_procurement_mcp/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:

```python
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`](https://github.com/malkreide/register-mcp).
- BKP / eBKP construction codes on a tender connect procurement to school-building
  planning and to [`zh-education-mcp`](https://github.com/malkreide/zh-education-mcp).
- Complements [`amtsblatt-mcp`](https://github.com/malkreide/amtsblatt-mcp) with
  national coverage instead of three cantons.

---

## Installation

```bash
uvx swiss-procurement-mcp
```

### Claude Desktop

```json
{
  "mcpServers": {
    "swiss-procurement": {
      "command": "uvx",
      "args": ["swiss-procurement-mcp"]
    }
  }
}
```

### Cloud (Render / Railway)

```bash
MCP_TRANSPORT=sse HOST=0.0.0.0 PORT=8000 python -m swiss_procurement_mcp
```

### Container

```bash
docker compose up --build        # SSE on :8000
```

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.

```json
{"ts":"2026-07-27T15:50:25","level":"INFO","logger":"swiss_procurement_mcp","msg":"tool_call","tool":"search_procurements","status":"ok","latency_ms":312}
```

---

## Testing

```bash
PYTHONPATH=src pyte
llmmcpmcp-servermodel-context-protocolopen-datapublic-procurementpythonsimapswiss-open-dataswiss-public-data-mcpswitzerland

What people ask about swiss-procurement-mcp

What is malkreide/swiss-procurement-mcp?

+

malkreide/swiss-procurement-mcp is mcp servers for the Claude AI ecosystem. MCP server for the simap.ch Swiss public procurement API — all cantons, read-only It has 1 GitHub stars and was last updated today.

How do I install swiss-procurement-mcp?

+

You can install swiss-procurement-mcp by cloning the repository (https://github.com/malkreide/swiss-procurement-mcp) or following the README instructions on GitHub. ClaudeWave also provides quick install blocks on this page.

Is malkreide/swiss-procurement-mcp safe to use?

+

malkreide/swiss-procurement-mcp has not been audited yet by our security agent. Review the original repository on GitHub before using it in production.

Who maintains malkreide/swiss-procurement-mcp?

+

malkreide/swiss-procurement-mcp is maintained by malkreide. The last recorded GitHub activity is from today, with 1 open issues.

Are there alternatives to swiss-procurement-mcp?

+

Yes. On ClaudeWave you can browse similar mcp servers at /categories/mcp, sorted by popularity or recent activity.

Deploy swiss-procurement-mcp to your cloud

Ship this repo to production in minutes. Each platform spins up its own environment with editable env vars.

Maintain this repo? Add a badge to your README

Drop the badge into your GitHub README to show it's tracked on ClaudeWave. Each badge links back to this page and reflects the live Trust Score.

Featured on ClaudeWave: malkreide/swiss-procurement-mcp
[![Featured on ClaudeWave](https://claudewave.com/api/badge/malkreide-swiss-procurement-mcp)](https://claudewave.com/repo/malkreide-swiss-procurement-mcp)
<a href="https://claudewave.com/repo/malkreide-swiss-procurement-mcp"><img src="https://claudewave.com/api/badge/malkreide-swiss-procurement-mcp" alt="Featured on ClaudeWave: malkreide/swiss-procurement-mcp" width="320" height="64" /></a>

More MCP Servers

swiss-procurement-mcp alternatives