Skip to main content
Glama
jocks-studio

vergabe-mcp

by jocks-studio
README.md
# vergabe-mcp

[![CI](https://github.com/jocks-studio/vergabe-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/jocks-studio/vergabe-mcp/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/vergabe-mcp)](https://www.npmjs.com/package/vergabe-mcp)

An MCP server for [oeffentlichevergabe.de](https://oeffentlichevergabe.de), the German federal
public-tender portal. Germany publishes every public tender there and offers a bulk open-data
export, but that export lags a day, ships as daily ZIPs, and cannot filter — you download
everything and sort it out yourself. The portal's own search API can filter (CPV code, region,
deadline window, the eForms "suitable for freelancers" flag) and is current the moment a notice
goes live, but it is undocumented. This server wraps it, so any MCP client can search the live
feed with real filters and pull a notice in the portal's domain model, in eForms XML, or in OCDS.
Anonymous, no API key, no account.

The reverse-engineered API reference is in [docs/API.md](docs/API.md) — it stands on its own if
you want to call the endpoints directly instead of through MCP.

## Install

Nothing to install; run it with `npx`.

**Claude Code**

```bash
claude mcp add vergabe -- npx vergabe-mcp
```

**Claude Desktop** — in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "vergabe": {
      "command": "npx",
      "args": ["vergabe-mcp"]
    }
  }
}
```

**Any other stdio client**

```bash
npx vergabe-mcp
```

**Streamable HTTP**, if your client wants a URL rather than a subprocess:

```bash
npx vergabe-mcp --http --port 3000
```

## Tools

| Tool | What it does |
|---|---|
| `search_tenders` | Search lots with flat parameters — free text, CPV prefixes, `suitableFor`, contract nature, NUTS region, platform, deadline and publication windows, paging, sort. Returns normalized results. |
| `get_tender` | One notice by id, as a curated `summary` (default) or verbatim `domain` JSON, `eforms` XML, or `ocds` JSON. |
| `search_tenders_raw` | The native `SELECT`/`FROM`/`WHERE`/`PAGE`/`ORDER` DSL, passed through and returned raw — for anything the curated tool does not express. |
| `lookup_code` | Resolve a CPV or NUTS code to its German or English description. |
| `get_export_url` | Build the bulk open-data export URL for a day or month, with the caveats attached. |

All five are read-only; the server writes nothing, stores nothing on disk, and holds no
credentials.

**Resources.** `vergabe://api-reference` serves the API document, so a client can read what the
backend actually supports before composing a query. Templates cover single notices —
`vergabe://notice/{noticeId}`, `.../eforms`, `.../ocds` — and `vergabe://codelist/{list}/{code}`.

**Prompt.** `find-solo-tenders` (args: `keywords`, `cpv`, `maxDays`) searches with the
freelancer/self-employed flags, then walks the model through triaging candidates: person-days
buried in the description, whether the near deadline is a request to participate or the offer
itself, and the fact that an empty value field means nothing. That triage knowledge ships as a
prompt rather than as scoring code, so you can argue with it.

## Example

> **You:** Find open IT tenders that are flagged as suitable for freelancers.

The model calls `search_tenders` with `cpv: ["72"]`, `suitableFor: ["freelance"]`,
`onlyActive: true`. On 2026-08-17 that is 30 open lots, among them:

```json
{
  "noticeId": "7c70692d-dfb9-4b96-b777-4a5539eaee50",
  "title": "Beschaffung einer Beratungsdienstleistung zur Fachberatung im Rahmen eines Identity and Access Management (IAM) Projektes für die Stadtwerke Bielefeld (SWB)",
  "lotTitle": "Beratungsdienstleistung zur Fachberatung im Rahmen eines Identity and Access Management (IAM) Projektes",
  "buyer": "Stadtwerke Bielefeld GmbH",
  "cpv": "72260000",
  "noticeType": "cn-standard",
  "nature": "services",
  "deadline": "2026-09-14T10:00:00+02:00",
  "published": "2026-08-12T00:00:00+02:00"
}
```

It then calls `get_tender` on the interesting ones to read the description, the estimated value,
and the exclusion grounds before telling you which are worth a bid — that judgment is the model's
job, and the server deliberately does not score anything.

Note the shape of the data in that result: the deadline on this one is
`deadlineReceiptRequests`, a request to participate, not an offer deadline. The
`find-solo-tenders` prompt exists because distinctions like that decide whether a tender is
reachable.

## Development

Node 20 or newer.

```bash
npm install
npm test            # vitest, no network
npm run typecheck
npm run lint        # biome
```

Integration tests hit the live portal and are skipped unless you ask for them:

```bash
VERGABE_LIVE=1 npm test
```

Commits follow [Conventional Commits](https://www.conventionalcommits.org/);
[release-please](https://github.com/googleapis/release-please) turns them into a release PR on
`main` — merging that PR tags the release, writes the changelog, publishes to npm, and attaches
the tarball to the GitHub release. The commit type decides the version bump.

## License

MIT.

TDQS

A4/5.0

Scored across 5 tools

Disambiguation5/5

Each tool targets a distinct operation: searching tenders, fetching details, raw querying, code lookup, and export URL generation. There is no overlap between their purposes.

Naming Consistency5/5

All tool names follow a clear verb_noun pattern (search_tenders, get_tender, search_tenders_raw, lookup_code, get_export_url). The conventions are uniform and predictable.

Tool Count5/5

Five tools are well-scoped for a public procurement domain. Each tool addresses a necessary function without being excessive or insufficient.

Completeness4/5

Core workflows for searching, fetching, raw querying, code lookup, and export are covered. Minor gaps like bulk export download instructions are handled via external guidance, which is acceptable.

Maintenance

ActivityMaintained
ResponsivenessSyncing