{
  "markdown": "# Public Tenders ES/EU\n\nNormalized search over public-sector tenders published in TED (Tenders Electronic Daily, the EU's\nofficial procurement notice platform) -- covers Spain plus the rest of the EU. NEXUS candidate #16 --\n**manual build, not FORGE-generated**, same manual-Cloud-Run-asset pattern as candidates #3/#4/#6/#8/#9/#13.\n\n- `POST /search-public-tenders {\"country\": \"ESP\", \"keyword\": null, \"cpv_prefix\": null, \"days_back\": 30, \"limit\": 20}`\n  -- **$0.01/call.**\n- MCP tool `search_public_tenders` at `/mcp`, same params -- **currently free**, see \"Known limitations\".\n- `GET /health`, `GET /.well-known/agent-card.json`, `GET /openapi.json` (has `x-payment-info`),\n  `GET /.well-known/402index-verify.txt` (402index claim verification file).\n\n## Source: pivoted from the original brief, with explicit sign-off (2026-08-23)\n\nThe task brief named `contrataciondelsectorpublico.gob.es/sindicacion/sindicacion_643/` (Spain's national\nPLACSP ATOM sindication feed) as \"ya grounded de la sesión anterior\". Verified live at the start of this\nsession via two independent fetch paths (`curl`, `WebFetch`, with and without a browser User-Agent): **403\nForbidden**, and the domain's own `robots.txt` is `Disallow: /` for the entire site -- a declared,\nsite-wide block, not a rate limit. That would very likely also block Cloud Run (a datacenter IP range,\nthe common target of this kind of WAF rule). Flagged to the user before writing any code (not silently\nsubstituted) -- user chose to pivot to TED.\n\n**TED (Tenders Electronic Daily, `api.ted.europa.eu`)** is the EU Publications Office's own official\npublic-procurement notice search API -- free, keyless, no documented auth. Verified live and field-by-field\nthis session (a third-party README got query-field names wrong on the first pass -- e.g. claimed\n`country-buyer` where the real API only accepts `buyer-country` -- so every field/operator below was\nconfirmed against the real API's own responses, not trusted secondhand):\n\n- `POST https://api.ted.europa.eu/v3/notices/search` with a JSON body of `query` (expert-search string),\n  `fields` (array of eForms field names to return), `limit`, `scope` (`\"ACTIVE\"` or `\"ALL\"`).\n- Query fields confirmed live: `publication-date>=YYYYMMDD` (8-digit, no dashes), `buyer-country=ESP`\n  (ISO-3166 alpha-3), `FT~\"phrase\"` (full-text), `AND`, `SORT BY publication-date DESC`.\n- Output fields confirmed live: `publication-number`, `notice-title` (multilingual dict, keys like `spa`/\n  `eng`), `buyer-name` (multilingual dict of lists), `buyer-country`, `publication-date`,\n  `classification-cpv` (list of CPV codes), `total-value` (not present on every notice type -- e.g.\n  qualification-system notices don't carry one, this is normal, not an error), `notice-type`, `links`\n  (per-language `html`/`pdf`/`xml` detail URLs).\n- Real query tested: `buyer-country=ESP` alone matched 26,314 notices in the `ACTIVE` scope at the time of\n  this check; a `publication-date` + `buyer-country` combo matched 1,058 in an 8-day window -- real, current\n  volume, not a stale/empty endpoint.\n\nTED already covers Spain (any tender above the EU publication threshold appears with `buyer-country=ESP`)\nplus the rest of the EU, so it fits the \"España/UE\" framing of the original brief at least as well as the\nblocked national sub-threshold feed would have -- arguably better, since it's EU-wide by construction and\nnot blocked. **Scope difference from what the blocked source would have given**: TED only carries notices\nabove the EU publication threshold (a real, non-trivial floor -- Spain's own sub-threshold tenders, the\nones PLACSP's national feed alone would have carried, are NOT in TED). This asset does not claim to cover\nthose; `total_matching_notices` and each result's `source: \"TED\"` field make the actual coverage explicit\nto a buyer, not implied to be broader than it is.\n\n## State (db_constraint exception)\n\nEvery sibling manual asset (`url-metadata-api`, `agent-verification-api`, `document-conversion-api`,\n`new-x402-listings-feed`, `onchain-activity-index`, `x402-receipt-verifier`) is fully stateless -- each call\nhits its upstream fresh, no NEXUS-owned table beyond the standard `traffic_events`/`revenue_events`/\n`mcp_call_events` telemetry trio. This is the first of the 8 manual/FORGE assets to need the case-by-case\nexception: a new table, **`tenders_query_cache`** (Supabase migration `add_tenders_query_cache_candidate16`,\nproject `ieduhdgfjdeffvzxvihf`), read+written only by this asset.\n\n**Why**: TED's own docs state there is no documented rate limit but ask callers to be reasonable with\nrequest volume on a shared public resource. Caching identical repeat queries (same country/keyword/\ncpv_prefix/days_back/limit, bucketed to the hour) for 1 hour is being a good citizen of that guidance, not\ngratuitous state -- a cache hit is marked `\"cache_hit\": true` in the response so a buyer can see when this\nhappened.\n\n**This is NOT the state the original PLACSP-scraping design would have needed.** That design (walking an\nunfilterable ATOM feed, tracking \"already seen\" tender IDs to detect deltas -- the same shape as\n`new-x402-listings-feed`'s catalog-walk cache) doesn't apply here: TED's own server-side\n`publication-date>=` filtering already answers \"what's new since X\" without this asset needing to remember\nanything about individual tenders itself. The only state kept is a short-lived response cache, not a tender\nindex.\n\n**RLS**: `anon` gets `SELECT` + `INSERT` on `tenders_query_cache` only -- no `UPDATE`/`DELETE` granted, same\ninsert-append discipline as every other NEXUS telemetry table. A cache \"overwrite\" is really just a new row\nwith a newer `cached_at`; the lookup query always picks the freshest row within the TTL window. Stale rows\npast the 1-hour TTL are never deleted -- unbounded growth over the 7-day probation window is negligible\n(one row per distinct query per hour) and accepted for now; a real cleanup would need either a scheduled job\nor a `DELETE` grant this asset doesn't otherwise need.\n\n## Deploy target: Cloud Run\n\nSame pipeline as candidates #4/#3/#6/#8/#9/#13 -- see `skills/infra-deploy-ops`.\n\n```bash\n# 1. First deploy -- PUBLIC_DOMAIN not known yet, every real request 421s until step 2.\n./scripts/deploy_cloud_run.sh public-tenders-es-eu manual_assets/public-tenders-es-eu\n\n# 2. Grab the printed *.run.app URL, then (only if it differs from env-vars.deploy.yaml's guess):\ngcloud run services update public-tenders-es-eu --region us-central1 --project nexus-505016 \\\n    --update-env-vars PUBLIC_DOMAIN=<the-real-domain>\n```\n\n## Known limitations (left unfixed on purpose -- CLAUDE.md SS3, no gate without evidence it's needed)\n\n- **MCP tool calls are not charged.** Same in-process-call pattern as every other manual asset in this\n  codebase.\n- **Only EU-publication-threshold tenders.** See \"Source\" above -- Spain's sub-threshold national tenders\n  are not in TED and not covered by this asset.\n- **`cpv_prefix` is filtered client-side, after fetching.** TED's query syntax doesn't support CPV-prefix\n  matching server-side; this asset over-fetches (3x `limit`, capped at 50) when `cpv_prefix` is set, then\n  filters in-process. A caller with a very narrow `cpv_prefix` against a broad `days_back` window could get\n  fewer real matches than exist in TED's full result set beyond what was over-fetched.\n- **Cache is a plain 1-hour TTL, not invalidated on new TED publications.** A query repeated within the same\n  hour bucket can return results that are up to ~1 hour stale relative to TED. Disclosed via `cache_hit` in\n  every response.\n- **No per-caller rate limiting.** Fine for a 7-day disposable measurement window.\n- **`tenders_query_cache` rows are never deleted.** See \"State\" above.\n\n## Quality gate (2026-08-23, from design not retroactive)\n\nSame 2-agent process as candidates #3/#4/#6/#8/#9/#13 (security lens; functional+quality+buyer-experience\nlens) -- results filled in below once run.\n\n## Measurement (candidate #16, 7-day window)\n\n7-day window from 2026-08-23 (real deploy date) -> decision point 2026-08-30. Source of truth:\n`traffic_events`/`revenue_events`/`mcp_call_events` (`asset_name = 'public-tenders-es-eu'`), not Cloud Run\nlogs. Day 7: if zero real traffic (filtering crawlers), pause/delete the Cloud Run service\n(`gcloud run services delete public-tenders-es-eu --region us-central1 --project nexus-505016`).\n",
  "bytes": 8389,
  "sha": "4d4ce8c05aad100f27d77ca1e49b466d194c947700a63961a68a4052c0943aea",
  "repo_slug": "nexus-mcp-infra/public-tenders-es-eu",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_nexus_mcp_infra_public_tenders_01cc248b/readme"
}