# TED Search API

> Enabling EU tender search from https://ted.europa.eu/ via API and MCP.

- **Type:** MCP server
- **Install:** `agentstack add mcp-kasey6801-ted-search-api`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [kasey6801](https://agentstack.voostack.com/s/kasey6801)
- **Installs:** 0
- **Category:** [Search](https://agentstack.voostack.com/c/search)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [kasey6801](https://github.com/kasey6801)
- **Source:** https://github.com/kasey6801/TED-Search-API

## Install

```sh
agentstack add mcp-kasey6801-ted-search-api
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# TED Search API

> Async Python client and MCP server for the EU's [Tenders Electronic Daily](https://ted.europa.eu/) -- the official journal of public procurement in the European Union.

## What is this, in plain English?

> *Skip this section if you already know what an API and an MCP server are. It's here for newcomers.*

**A public API is a way for one piece of software to ask another for information.** The EU runs a website at  where people can search for public tenders. They also expose a programmatic version of that search -- a URL you can send a request to and get back structured data instead of a web page. That URL is the **API**: `https://api.ted.europa.eu/`. It's free, it doesn't require a password, and anyone can use it.

**A client (or "wrapper") is friendly middleware between you and the API.** Calling an API directly is fiddly: you have to format the request perfectly, handle errors, page through long results, and parse the response. A wrapper takes those rough edges off. You say *"find me French tenders for road resurfacing"* in a programming language, and the wrapper handles the awkward parts and gives you a clean answer. The Python part of this project, `TedSearchClient`, is exactly that wrapper.

**MCP (Model Context Protocol) lets AI assistants use wrappers as tools.** AI assistants like Claude can't reach out to the internet on their own -- they need a connector. MCP is an open standard for that connector. Once you tell Claude "this project's MCP server exists," Claude can call its `search_notices` tool inside any conversation -- you ask in English, Claude calls the wrapper, the wrapper calls the EU, and the answer comes back. No coding required on your end.

**Putting it together:** this project is the wrapper *plus* the MCP adapter. Use the Python wrapper directly if you're writing code; use the MCP server if you want Claude (or any MCP-compatible assistant) to use the EU tender data on your behalf. The rest of this README shows how to do each.

TED publishes every public-sector tender in the EU above certain monetary thresholds. The EU exposes a free, keyless HTTP search API. This project wraps that API in two ways:

1. **A reusable async Python client** -- `TedSearchClient` -- that any Python program can import.
2. **An MCP (Model Context Protocol) server** -- `ted-search-mcp` -- that exposes TED search as a single `search_notices` tool to Claude Desktop, Claude Code, and any other MCP-compatible AI client.

A small typer-based CLI (`ted-search`) is also included for live smoke-testing.

**Status:** Milestone 1 (shared client + CLI) and Milestone 2 (MCP server) are complete. The MCP server is verified end-to-end against the live API. See [`DESIGN.md`](DESIGN.md) for architecture rationale and the full milestone breakdown.

---

## Quickstart

You'll need [`uv`](https://docs.astral.sh/uv/) installed.

```bash
git clone https://github.com/kasey6801/TED-Search-API.git
cd TED-Search-API
uv sync
```

### Use it from the command line

```bash
uv run ted-search "publication-date >= 20260501" --limit 5 --scope ACTIVE
```

Sample output:

```
Total matches: 52240  (showing 5)
--------------------------------------------------------------------------------
[2026-05-04+02:00] daec2c64-4e45-4e9f-81b7-2a1510f39c54 | ROU | UM 0929 Bucuresti
    Prestare servicii de mentenanță aparatură medicală, de stomatologie și laborator
[2026-05-04+02:00] db5ccb01-87ba-4d99-8aed-98ae2e3d8934 | FRA | Dijon Métropole
    DIJON METROPOLE -- Relance réfection des couches de roulement...
...
```

Pass `--json` for the raw structured response, `--fields-preset none` to use the server's default field set, `--page N` to paginate.

### Use it from Python

```python
import asyncio
from ted_search_api import TedSearchClient, PRESET_SUMMARY

async def main():
    async with TedSearchClient() as ted:
        result = await ted.search(
            "publication-date >= 20260501",
            fields=PRESET_SUMMARY,
            limit=10,
            scope="ACTIVE",
        )
        print(result.totalNoticeCount, "matches")
        for n in result.notices:
            print(n["notice-identifier"], n.get("buyer-name"))

asyncio.run(main())
```

For unbounded result sets, use the async iterator:

```python
from ted_search_api import TedSearchClient
from ted_search_api.paging import iter_notices

async with TedSearchClient() as ted:
    async for notice in iter_notices(ted, "publication-date >= 20260101", limit=100):
        ...
```

### Use it as an MCP server

The server speaks the [Model Context Protocol](https://modelcontextprotocol.io/) over stdio. Any MCP-compatible host -- Claude Desktop, Claude Code, Cursor, Continue.dev, Cline, Zed, or a custom client built on the official Python / TypeScript SDKs -- can spawn it as a subprocess and call the `search_notices` tool.

You don't normally launch the server by hand; the host does. You can sanity-check that it boots with:

```bash
uv run ted-search-mcp        # waits on stdin for MCP JSON-RPC frames; Ctrl-C to exit
```

#### What every host needs to know

Every MCP host configures servers with the same three pieces of information:

| Field | Value for `ted-search` |
|---|---|
| **Server name** | A label you choose, e.g. `ted-search`. |
| **Command** | The absolute path to your `uv` binary. Run `which uv` to find it -- on macOS it is typically `/Users/YOU/.local/bin/uv`. |
| **Args** | `["--directory", "/path/to/TED-Search-API", "run", "ted-search-mcp"]` |

> **Why the absolute path to `uv`?** MCP hosts spawn subprocesses with a minimal `PATH` that usually does *not* include `~/.local/bin`. If you write `"command": "uv"` and the host can't resolve it, the server silently fails to launch and the tool simply never appears. Hardcoding the full path eliminates this entire class of bug.

#### Generic JSON config

Most hosts use a JSON config file with this exact shape:

```json
{
  "mcpServers": {
    "ted-search": {
      "command": "/Users/YOU/.local/bin/uv",
      "args": [
        "--directory",
        "/Users/YOU/path/to/TED-Search-API",
        "run",
        "ted-search-mcp"
      ]
    }
  }
}
```

The shape is identical across hosts; only the *file* you drop it into differs:

| Host | Config file (or command) |
|---|---|
| **Claude Desktop** (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| **Claude Desktop** (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` |
| **Claude Code** | `~/.claude.json` -- or run `claude mcp add` (see below) |
| **Cursor** | `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project) |
| **Continue.dev** | `~/.continue/config.json`, under an `mcpServers` key |
| **Cline** (VS Code extension) | VS Code Settings → search "Cline: MCP" → edit the servers JSON |
| **Anything else** | Check your host's MCP docs; the JSON shape above is the de facto convention. |

After editing, **fully restart the host application** -- close all windows *and* quit the menu-bar / background process. Closing the window alone is usually not enough.

#### CLI shortcut (Claude Code)

If you have the `claude` CLI installed, skip the JSON edit entirely:

```bash
claude mcp add ted-search --scope user -- \
  /Users/YOU/.local/bin/uv \
  --directory /Users/YOU/path/to/TED-Search-API \
  run ted-search-mcp
```

(`--scope user` makes it available across every project. Drop the flag for project-only scope, which writes a local `.mcp.json` instead.) The VS Code Claude Code extension reads the same registry as the CLI.

#### From a custom MCP client

`scripts/mcp_smoke.py` in this repo is a ~60-line working example. The core pattern:

```python
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(
    command="/Users/YOU/.local/bin/uv",
    args=["--directory", "/path/to/TED-Search-API", "run", "ted-search-mcp"],
)
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
    await session.initialize()
    tools = await session.list_tools()             # → [Tool(name='search_notices', ...)]
    result = await session.call_tool(
        "search_notices",
        arguments={"query": "publication-date >= 20260501", "limit": 5, "scope": "ACTIVE"},
    )
    print(result.structuredContent)
```

#### Verifying it works

Once the host has spawned the server, the `search_notices` tool is available in any conversation. Test it with something like:

> *"Use the ted-search tool to find 5 active French tenders for road resurfacing published since May 1st 2026."*

The tool returns a compact JSON summary: `total_matches`, `returned`, `page`, `next_page`, `has_more`, and a list of notices (each with `id`, `publication_date`, `title`, `buyer_country`, `buyer_name`, `cpv_codes`). The host surfaces it as a tool-call invocation; the assistant interprets the result.

If the tool doesn't appear after a restart, check the host's MCP logs for stderr from the spawn -- almost always either a `PATH`/`command not found` issue (use the absolute path) or a stale config file path.

---

## The query language

The `query` argument uses TED's [expert-search DSL](https://ted.europa.eu/en/search/expert-search). It's a small SQL-`WHERE`-like syntax. A few non-obvious things worth knowing:

- **Dates are literal `YYYYMMDD` strings.** There is **no** `today(-N day)` function -- a query like `publication-date >= today(-7day)` will be rejected with a structured syntax error.
- **The `fields` parameter is required** even though the OpenAPI spec marks it optional. Sending without it returns "Validation error".
- **The API silently drops field names that don't apply to a given notice** -- no warning, just absence. The correct field for buyer name is `buyer-name` (multilingual dict), not `organisation-name-buyer`.

Examples:

```text
publication-date >= 20260501
publication-date >= 20260501 AND organisation-country-buyer = "FRA"
classification-cpv = "45000000"
notice-type = "cn-standard"
```

See the [TED expert-search reference](https://ted.europa.eu/en/search/expert-search) for the full grammar and the [TED OpenAPI spec](https://ted.europa.eu/docs/v3) for the complete list of ~1830 valid field names.

---

## Data source: TED and the eForms transition

Every result this client returns comes directly from [Tenders Electronic Daily](https://ted.europa.eu/), the EU's official journal of public procurement notices. Member states upload notices to a common schema and TED re-publishes them daily. The corpus is large -- over **50 000 active notices** at any one time and tens of millions historically -- and goes back decades.

**The data has two eras.** In October 2023 the EU migrated to **eForms**, a much richer structured schema, and made it mandatory for new publications. The TED API exposes both eras through the same endpoint, but the *structural* fields differ sharply between them.

Empirical population rates from random 100-notice samples (taken 2026-05-25):

| Field | Pre-2024 (legacy schema) | 2024 onwards (eForms) |
|---|---:|---:|
| `publication-date` | 100 % | 100 % |
| `buyer-name` *(multilingual)* | 100 % | 100 % |
| `classification-cpv` | 100 % | 100 % |
| `notice-identifier` *(UUID)* | **0 %** | 100 % |
| `title-proc` *(multilingual)* | **0 %** | 100 % |
| `organisation-country-buyer` | **0 %** | 100 % |
| `total-value` | ~68 % (90 % on `can-standard` awards) | ~57 % |
| `total-value-cur` *(currency code)* | ~70 % | ~61 % |

**Practical consequences for your queries:**

- **Filter or sort on the missing-in-legacy fields** -- `notice-identifier`, `title-proc`, `organisation-country-buyer` -- and you will silently exclude pre-2024 notices. The API does *not* warn about this.
- **Span both eras** and ~half your records will lack core structured fields. The MCP `search_notices` tool reports a `validation_warnings` count for exactly this case; treat any non-zero value as a cue to narrow the date range or relax the downstream logic.
- **Value fields populate inconsistently** even in the eForms era. Roughly 60 % of recent notices carry a `total-value`; the rest are framework agreements, ongoing competitions, or notice types where the field doesn't apply. Don't assume it's present.

**Recommended date floor for analysis:** `publication-date >= 20240101`. Below that the field-population gap is severe enough that aggregate statistics over a mixed sample will be biased toward whichever era happens to dominate.

If you specifically need historical (pre-2024) data, the EU also publishes the **TED bulk XML daily packages** through the EU Open Data Portal -- they carry the full original notice text under the legacy schema. Cross-referencing between the two sources is left to the caller; this client wraps only the REST search endpoint.

The drift-detection scripts (`scripts/spec_diff.py`, `scripts/canary.py`) and the strict `NoticeSummary` validation in `summarise()` are all designed against this two-era reality. See [`DESIGN.md` § 6](DESIGN.md) for the full risk inventory.

---

## Project structure

```
src/ted_search_api/
├── __init__.py     # public re-exports
├── client.py       # TedSearchClient (async, httpx-based)
├── models.py       # Pydantic SearchRequest / SearchResponse
├── errors.py       # TedAPIError / TedHTTPError / TedQueryError
├── paging.py       # iter_notices() async generator
├── fields.py       # PRESET_SUMMARY, PRESET_BUYER_AND_VALUE
├── cli.py          # `ted-search` typer entrypoint
└── mcp/
    ├── __init__.py
    └── server.py   # FastMCP server, `search_notices` tool
tests/              # 10 tests, offline via VCR cassettes
scripts/
└── mcp_smoke.py    # MCP stdio round-trip against the live API
```

See [`DESIGN.md`](DESIGN.md) § 4 for the layout rationale (single `src`-layout package, not three).

---

## Development

```bash
uv sync                                 # install deps
uv run pytest                           # 10 tests, offline via VCR cassettes
uv run pytest --record-mode=rewrite     # re-record cassettes against the live API
uv run ruff check .                     # lint
uv run mypy src                         # strict type-check (zero issues across 9 files)
uv run python scripts/mcp_smoke.py      # MCP stdio round-trip against the live API
```

---

## Operational guardrails

The TED API is publicly maintained and can change between releases. Three guardrails ship with the project so drift surfaces loudly instead of poisoning downstream results silently.

```bash
uv run python scripts/spec_diff.py        # diff live OpenAPI spec vs pinned snapshot
uv run python scripts/spec_diff.py --update   # re-pin after reviewing a benign diff
uv run python scripts/canary.py -v         # assert 5 frozen historical notices still match
```

In addition, every notice flowing through the MCP `search_notices` tool is run through a strict `NoticeSummary` Pydantic model (`src/ted_search_api/models.py`). Unknown upstream fields are tolerated silently, but malformed values on known fields are surfaced via the response's `validation_warnings` counter. A non-zero count is the assistant's cue to flag results for human review rather than treat them as authoritative.

Run any of the three scripts manually, on a cron, or as a GitHub Action -- they each exit non-zero on drift, so wiring them into a scheduler is one line.

## Limitations and roadmap

- **No automatic retries** on 429 / 5xx -- failures surface as `TedHTTPError`. The TED API does not document rate limits; client-side backoff is left to the caller.
- **MCP tool caps `limit` at 50** (the raw API allows 250). This keeps LLM context manageable; raise via the `MAX_LIMIT` constant in `src/ted_search_api/mcp/server.py` if you need more per call.
- **`paginationMode=PAGE_NUMBER` is capped server-side at 15 000 results.** For larger sweeps, call `iter_notices(..., mode="ITERATION")` -- it uses opaque continuation tokens and has no fixed cap.
- **Milestone 3 (FastAPI HTTP wrapper) is deliberately n

…

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [kasey6801](https://github.com/kasey6801)
- **Source:** [kasey6801/TED-Search-API](https://github.com/kasey6801/TED-Search-API)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** yes
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-kasey6801-ted-search-api
- Seller: https://agentstack.voostack.com/s/kasey6801
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
