Async Python client and MCP server for the EU's Tenders Electronic Daily -- the official journal of public procurement in the European Union.
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 https://ted.europa.eu/ 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:
- A reusable async Python client --
TedSearchClient-- that any Python program can import. - An MCP (Model Context Protocol) server --
ted-search-mcp-- that exposes TED search as a singlesearch_noticestool 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 for architecture rationale and the full milestone breakdown.
You'll need uv installed.
git clone https://github.com/kasey6801/TED-Search-API.git
cd TED-Search-API
uv syncuv run ted-search "publication-date >= 20260501" --limit 5 --scope ACTIVESample 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.
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:
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):
...The server speaks the Model Context Protocol 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:
uv run ted-search-mcp # waits on stdin for MCP JSON-RPC frames; Ctrl-C to exitEvery 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 minimalPATHthat 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.
Most hosts use a JSON config file with this exact shape:
{
"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.
If you have the claude CLI installed, skip the JSON edit entirely:
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.
scripts/mcp_smoke.py in this repo is a ~60-line working example. The core pattern:
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)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 argument uses TED's expert-search DSL. It's a small SQL-WHERE-like syntax. A few non-obvious things worth knowing:
- Dates are literal
YYYYMMDDstrings. There is notoday(-N day)function -- a query likepublication-date >= today(-7day)will be rejected with a structured syntax error. - The
fieldsparameter 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), notorganisation-name-buyer.
Examples:
publication-date >= 20260501
publication-date >= 20260501 AND organisation-country-buyer = "FRA"
classification-cpv = "45000000"
notice-type = "cn-standard"
See the TED expert-search reference for the full grammar and the TED OpenAPI spec for the complete list of ~1830 valid field names.
Every result this client returns comes directly from Tenders Electronic Daily, 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_noticestool reports avalidation_warningscount 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 for the full risk inventory.
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 § 4 for the layout rationale (single src-layout package, not three).
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 APIThe 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.
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 matchIn 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.
- 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
limitat 50 (the raw API allows 250). This keeps LLM context manageable; raise via theMAX_LIMITconstant insrc/ted_search_api/mcp/server.pyif you need more per call. paginationMode=PAGE_NUMBERis capped server-side at 15 000 results. For larger sweeps, calliter_notices(..., mode="ITERATION")-- it uses opaque continuation tokens and has no fixed cap.- Milestone 3 (FastAPI HTTP wrapper) is deliberately not built -- only worth doing if a non-Python, non-MCP consumer appears. See
DESIGN.md§ 5.
MIT © 2026 Kévin C (@kasey6801). See the LICENSE file for the full text.
DESIGN.md-- full architecture rationale, alternatives considered, milestone breakdown, beginner-friendly glossary.- TED official search UI -- the same data via the EU's web interface.
- TED OpenAPI spec -- live machine-readable spec.
- TED expert-search DSL reference -- query language.
- Model Context Protocol -- the open protocol the MCP server speaks.