Skip to main content
Glama
tender-radar-mcp

tender-radar-mcp

by zohaibaamer
README.md
# Tender Radar

[![CI](https://github.com/ZohaibAamer/tender-radar/actions/workflows/ci.yml/badge.svg)](https://github.com/ZohaibAamer/tender-radar/actions/workflows/ci.yml)

**A Public Tender Intelligence Agent: unified UK + EU procurement data, served
to AI agents through a governed MCP server, proven by an eval suite.**

Ask it *"Which open tenders fit a 50-person UK data consultancy?"* and it
answers with ranked matches and cited source links — through any MCP-capable
AI client, MCP Inspector, or the bundled CLI agent.

---

## The problem

Public-sector contracts are a huge market that small and mid-sized companies
systematically miss, for a mundane reason: **the notices are scattered.**

- The UK publishes above-threshold tenders on **Find a Tender** and
  lower-value ones on **Contracts Finder** — in OCDS format, and often the
  same tender appears on both portals with different IDs.
- The EU publishes on **TED** — in a completely different standard (eForms),
  across 24 languages.

An SME bid team can't watch three portals in two data standards. And if you
put an LLM on top of raw data, you trade one problem for another: agents that
write their own SQL produce confident, wrong numbers — silently adding euros
to pounds, averaging framework ceilings into contract values, citing tenders
that don't exist.

Tender Radar solves both halves: **one unified dataset** and **an agent that
can only query it through governed, tested paths.**

## What it does

| Capability | How |
|---|---|
| Unifies 3 portals, 2 standards | One OCDS-shaped schema; one mapper per standard |
| Deduplicates | Lifecycle grouping by OCDS `ocid` + cross-portal matching with **measured precision 0.93 / recall 0.89** |
| Serves agents | MCP server (stdio) with 5 tools + a discoverable semantic-layer resource |
| Governs queries | Agents aggregate only via `query_metric`, compiled from [one YAML file](src/tender_radar/semantic/layer.yaml) — **no free-SQL tool exists** |
| Matches companies | YAML company profiles (Pydantic-validated) ranked against open tenders with reasons |
| Proves it works | 52 agent evals with SQL-derived gold answers, incl. governance negatives and prompt-injection tests |

## Architecture overview

```
  UK Find a Tender      UK Contracts Finder        EU TED
  (OCDS releases)        (OCDS releases)         (eForms API)
        │                      │                      │
        └───────── one OCDS mapper ──────────── eForms mapper
                       │                              │
                       ▼                              ▼
              Python ingest  →  data/raw/{source}.parquet
                  (idempotent merge, keyed per notice)
                                 │
                                 ▼
                       dbt on DuckDB
         staging (views) → core (notices, tenders, CPV) → marts
                                 │
                fct_tenders ← enforced model contract
                                 │
           ┌─────────────────────┼─────────────────────┐
           ▼                     ▼                     ▼
   semantic layer         MCP server (stdio)     dedupe matcher
   (YAML → SQL           5 tools, 1 resource,    links + confidence,
    compiler)             NO free-SQL tool       measured P/R
                                 │
                                 ▼
               any MCP client: desktop AI apps,
               MCP Inspector, or the CLI agent
                   (Gemini Flash, swappable)
                                 │
                                 ▼
                  evals: 52 questions, SQL gold
                  answers, frozen 511-notice fixture
```

**The flow in words:** Python clients fetch each portal and map every notice
into one `UnifiedNotice` schema, merging idempotently into per-source Parquet.
dbt builds the warehouse in textbook layers — staging views per source, a
core layer that groups a tender's lifecycle notices (planning → tender →
award) by their shared `ocid`, and a single mart (`fct_tenders`) protected by
an enforced dbt contract. On top of that mart sit three consumers: a
hand-rolled semantic layer that compiles declared metrics to SQL, the MCP
server that exposes everything agents may do, and the cross-portal matcher
that links the same tender across UK portals. Deep dive: [ARCHITECTURE.md](ARCHITECTURE.md),
decisions: [docs/adr/](docs/adr/).

**Design invariants:**

1. **Governance lives in the server, not the agent.** There is no tool that
   accepts SQL, so no client — however prompted or jailbroken — can bypass
   the semantic layer ([ADR-0001](docs/adr/0001-governance-in-the-server.md)).
2. **The clock is injectable** (`TENDER_RADAR_TODAY`), so "open tender" and
   "deadline within 7 days" mean the same thing on live data and the frozen
   test fixture.
3. **Money is guarded.** Value metrics refuse to mix currencies and exclude
   framework ceilings by default — the YAML documents both rules.
4. **Tender text is data, not instructions.** Descriptions are third-party
   input; prompt-injection evals against a planted hostile tender verify the
   agent treats them that way.

## Tech stack

| Layer | Technology | Why |
|---|---|---|
| Language | Python 3.12, `uv` | Fast, reproducible env management |
| Ingestion | `httpx`, Pydantic | Typed unified schema at the edge |
| Storage | Parquet + DuckDB | Zero-infra analytical warehouse |
| Transform | dbt (`dbt-duckdb`) | Layered models, tests, contracts, source freshness |
| Semantic layer | Hand-rolled YAML + compiler | Governance legible in one file ([ADR-0003](docs/adr/0003-hand-rolled-semantic-layer.md)) |
| Agent interface | MCP Python SDK (stdio) | Works with MCP Inspector and any MCP-capable client |
| LLM | Gemini Flash via `google-genai` | Free tier; swappable behind a 3-method protocol |
| CI/CD | GitHub Actions | Tier-1 checks per push; on-demand data refresh + LLM evals |
| Data persistence | GitHub Release assets | No credentials, one-command download ([ADR-0004](docs/adr/0004-data-on-release-assets.md)) |
| Lint/test | ruff, pytest | 42 deterministic tests incl. the eval scorer itself |

## Project structure

```
tender-radar/
├── src/tender_radar/
│   ├── config.py            # paths + the injectable clock (TENDER_RADAR_TODAY)
│   ├── profiles.py          # Pydantic company profiles (YAML + generated JSON Schema)
│   ├── ingest/              # source clients, mappers, idempotent parquet store
│   │   ├── models.py        #   UnifiedNotice — the one schema everything maps into
│   │   ├── ocds.py          #   shared mapper for both UK portals
│   │   ├── ted.py           #   eForms → unified mapper (the cross-standard one)
│   │   └── store.py         #   keyed merge: reruns never duplicate
│   ├── dedupe/              # cross-portal matcher (blocking + scoring + links)
│   ├── semantic/            # layer.yaml (the governed surface) + SQL compiler
│   ├── mcp_server/          # stdio MCP server: 5 tools, semantic://layer resource
│   └── agent/               # ~100-line loop + swappable LLM provider
├── dbt/                     # staging → core → marts; CPV seed; contracts
├── profiles/                # company profiles as data (+ JSON Schema)
├── evals/                   # 52 questions, SQL golds, harness, dedupe labels
├── fixtures/                # frozen 511-notice eval fixture (script-built)
├── scripts/                 # fixture builder, matcher evaluation, schema gen
├── tests/                   # 42 deterministic tests (tier 1)
├── .github/workflows/       # ci.yml (every push) + data-refresh.yml (on demand)
├── ARCHITECTURE.md          # diagrams and component walkthrough
├── docs/adr/                # six architecture decision records
└── DATA_LICENSE.md          # OGL v3 / EU reuse attribution
```

## Use it right now — no install

The server is hosted as a public MCP connector:

```
https://tender-radar-lime.vercel.app/mcp
```

Add it in **Claude → Settings → Connectors → Add custom connector** (works on
claude.ai and Claude Desktop; ChatGPT's connector settings accept the same
URL). Then ask: *"Which open tenders fit a 50-person UK data consultancy?"* —
or read a [real transcript of exactly that](docs/demo.md).

The hosted copy serves a bundled snapshot of the warehouse (published
notices from all three sources); run it yourself for fresher data.

## Security, privacy & scale

The hosted connector is public and unauthenticated by design, so the
deployment is built to make that safe and free:

- **No SQL surface.** There is deliberately no tool that accepts SQL. Every
  filter is parameterized server-side, and every database connection is
  opened read-only — even a bug cannot write.
- **No secrets.** The server holds no API keys. The LLM runs on the caller's
  side (their own Claude/ChatGPT account); the server only executes typed
  tool calls against a local file.
- **Nothing stored.** No accounts, no query logs, no analytics. Requests are
  processed in memory against a read-only snapshot and forgotten; platform
  logs are ephemeral. The only data the server has is public procurement
  data.
- **Rate limited.** Each client IP gets 100 requests/minute per instance
  (HTTP 429 beyond that, verified end-to-end), on top of the platform's
  built-in DDoS mitigation — one looping client can't exhaust the free-tier
  quota for everyone else.
- **Concurrent users are free.** The warehouse ships inside the deployment
  as a read-only DuckDB file, so parallel reads don't contend. Measured with
  `scripts/load_test.py` against production (10 parallel clients × 3
  rounds): **30/30 succeeded, p50 0.52 s, p95 0.99 s**; the only slow call
  (5.4 s) was a cold start.

## How to run it

Prerequisites: [uv](https://docs.astral.sh/uv/) (manages Python itself).
Everything below is idempotent — rerunning is always safe. Every step also
has a `make` shortcut — run `make help` for the list.

### 1. Install

```bash
git clone https://github.com/ZohaibAamer/tender-radar && cd tender-radar
uv sync
```

### 2. Get data and build the warehouse

```bash
# ingest live notices from all three portals (public APIs, no keys needed)
uv run python -m tender_radar.ingest --source all --since 2026-10-01

# build the DuckDB warehouse with dbt
DBT_PROFILES_DIR=dbt uv run dbt deps --project-dir dbt
DBT_PROFILES_DIR=dbt uv run dbt build --project-dir dbt

# compute cross-portal duplicate links
uv run python -m tender_radar.dedupe
```

(If a `data-latest` release has been published, you can skip ingestion:
`gh release download data-latest --dir data/raw`.)

### 3. Use it from an MCP client

Fastest look — the official Inspector UI:

```bash
npx @modelcontextprotocol/inspector uv run tender-radar-mcp
```

Any MCP-capable AI client (standard `mcpServers` config):

```json
{
  "tender-radar": {
    "command": "uv",
    "args": ["run", "--directory", "/path/to/tender-radar", "tender-radar-mcp"]
  }
}
```

Tools: `search_tenders` · `get_tender` · `match_profile` ·
`upcoming_deadlines` · `query_metric` · `list_company_profiles`. The
semantic layer is readable as the `semantic://layer` resource, so agents
discover metrics instead of guessing.

### 4. Ask the CLI agent

```bash
export GEMINI_API_KEY=...   # free tier: https://aistudio.google.com
uv run python -m tender_radar.agent \
  "Which open tenders fit a 50-person UK data consultancy?"
```

Company profiles are plain YAML — copy
[profiles/example-uk-data-consultancy.yaml](profiles/example-uk-data-consultancy.yaml)
and describe your own company.

### 5. Run the tests and evals

```bash
# tier 1 — deterministic, no LLM, what CI runs on every push
uv run ruff check . && uv run pytest

# the matcher's measured scorecard (precision/recall sweep)
uv run python scripts/evaluate_matcher.py

# tier 2 — the 52-question agent eval suite (needs GEMINI_API_KEY)
TENDER_RADAR_DATA_DIR=$PWD/fixtures DBT_PROFILES_DIR=dbt \
  uv run dbt build --project-dir dbt          # fixture warehouse, once
uv run python evals/harness.py                # responses cached per question
uv run python evals/harness.py --category injection
```

## Data refresh and storage

The **Data refresh & evals** workflow (Actions tab, run on demand) ingests
the last 7 days from all three sources, rebuilds the warehouse, runs
`dbt source freshness` as the pipeline health check, recomputes dedup links,
and uploads Parquet to the rolling [`data-latest` release](../../releases),
optionally cutting a dated snapshot. **Nothing runs unattended by design** —
this is a reference implementation, not an operated service. To run it
nightly on your own fork, add the `cron` trigger shown at the top of
[data-refresh.yml](.github/workflows/data-refresh.yml).

## Licensing

Code is [MIT](LICENSE). Tender data belongs to its sources and is reused
under their terms — Open Government Licence v3.0 (both UK portals declare it
in their API responses) and the EU's reuse policy for TED. Full attribution
and the synthetic-test-row disclosure: [DATA_LICENSE.md](DATA_LICENSE.md).

## Honest limitations

- The TED mapper carries a pragmatic field subset (title, buyer, CPV, value,
  deadline, links); eForms is far richer than any flat schema.
- Cross-portal matching is title-driven: a renamed re-publication scoring
  below the measured 0.75 threshold is missed — a trade-off that is measured
  (P 0.93 / R 0.89), not hidden.
- Disclosed values are floors, not market sizes: many notices omit value,
  and the semantic layer's metric descriptions say so.

TDQS

B3.3/5.0

Scored across 6 tools

Disambiguation4/5

Each tool has a distinguishable role: keyword search, ref-based detail, profile ranking, profile listing, deadline windows, and governed aggregation. The only mild overlap is that search_tenders, match_profile, and upcoming_deadlines all return tender lists, but the descriptions make the distinguishing dimension (keywords vs. profile fit vs. deadline horizon) clear.

Naming Consistency4/5

Five of six names follow a clean verb_noun pattern (search_tenders, get_tender, match_profile, list_company_profiles, query_metric). upcoming_deadlines breaks the pattern as a bare noun phrase, a minor but noticeable deviation.

Tool Count5/5

Six tools is well-scoped for a tender discovery service: discovery, detail, profiling, and aggregation each get one entry point. No redundant or filler tools are present.

Completeness4/5

The surface covers the core read-only lifecycle: find tenders, inspect one in full with cross-portal links, rank by profile, track deadlines, and aggregate via a governed metric. Minor gaps exist around exporting/saving results or listing available sources, but these are workarounds rather than dead ends.

Maintenance

ActivityNo data
ResponsivenessOutdated data