Skip to main content
Glama
VladyslavMykhailyshyn

Prozorro.Sale MCP Server

README.md
# Prozorro.Sale MCP Server

Read-only MCP server for [Prozorro.Sale](https://prozorro.sale), the Ukrainian state platform
that auctions public assets — small and large privatisation, land sale and rental, property
lease, timber, subsoil, arrested and bankruptcy assets, sanctioned assets and non-performing
loans.

The tools are organised around what people actually ask, not around HTTP endpoints. There is
no `GET /procedures/{id}/documents/{doc_id}/history` tool; there is "show me the documents for
this auction". Nothing writes: no bids are placed, no procedures are created or modified.

## Tools

| Tool | Question it answers |
| --- | --- |
| `search_auctions` | "What land is up for rent near Lviv under 50 000 ₴?" |
| `get_auction` | "Tell me everything about SPD001-UA-20260806-70620." |
| `get_auction_timeline` | "How long do I have left to bid, and what happens next?" |
| `get_auction_results` | "What did it finally sell for, and how many bidders were there?" |
| `get_auction_documents` | "Give me the technical specification and the signed contract." |
| `find_comparable_auctions` | "Is this starting price reasonable for this kind of asset?" |
| `track_auction_changes` | "What new lots appeared in my niche since yesterday?" |
| `analyze_auction_market` | "How much timber was auctioned last week, and how much of it sold?" |
| `list_procedure_types` | "Which asset programmes and auction formats exist?" |
| `get_publication_constraints` | "If I list a lot today, how soon can the auction run?" |

Every tool accepts the public auction id printed on the website
(`LRE001-UA-20260708-03894`) as well as the internal 24-character procedure id.

## Tool reference

### `search_auctions`

Entry point for any "what is available" question.

| Parameter | Type | Notes |
| --- | --- | --- |
| `procedure_type` | `string[]` | Asset programme or exact selling method. `"landRental"` expands to every format it supports; `"landRental-english"` is used as-is. **Name it whenever you can** — the server then queries the per-type feeds and covers far more relevant auctions. |
| `open_for_bids` | `boolean` | "What can I bid on" — keeps only auctions whose deadline has not passed. |
| `keyword` | `string` | Ukrainian free text over title, description, items, classification and locality. All words must match. |
| `status` | `string[]` | e.g. `["active_tendering"]`, `["complete"]`. See `list_procedure_types`. |
| `region` | `string` | Substring of oblast or settlement, e.g. `"Львів"`. |
| `classification` | `string` | CAV/CPV code or prefix, e.g. `"07"` or `"07123000-7"`. |
| `min_value` / `max_value` | `number` | Starting-price band. |
| `seller` / `seller_edrpou` | `string` | The body **running** the auction — on state-property leases usually a regional SPFU office, not the institution. |
| `property_owner` / `property_owner_edrpou` | `string` | The organisation the asset **belongs to** (балансоутримувач). See [Owner vs seller](#owner-vs-seller). |
| `published_from` / `published_to` | `string` | ISO date bounds on publication. |
| `changed_since` / `changed_until` | `string` | Scan a specific historical period instead of recent activity. |
| `limit` | `number` | Results returned, 1–100. Default 20. |
| `scan_pages` | `number` | Feed pages to scan, 1–40. Default 8. Raise when results come back thin or truncated. |

```jsonc
// Property lease near Lviv, still open, under 50 000 UAH
{"procedure_type": ["legitimatePropertyLease"], "open_for_bids": true,
 "region": "Львів", "max_value": 50000, "limit": 10}

// Everything privatisation-related that concluded in July 2026
{"procedure_type": ["smallPrivatization", "largePrivatization"], "status": ["complete"],
 "changed_since": "2026-07-01", "changed_until": "2026-07-31", "scan_pages": 25}
```

Returns compact summaries plus a `scanned` block. An empty result means "not in the scanned
window", never "does not exist".

#### Owner vs seller

On the `legitimatePropertyLease` family the two roles are different organisations. A room in a
university building is auctioned by the regional SPFU office, so the university appears as
`propertyOwner` (балансоутримувач) and never as `seller`. Filtering by `seller` for an institution
returns nothing at all.

```jsonc
// Everything Lviv Polytechnic put up for lease this year — EDRPOU is exact, prefer it to a name
{"procedure_type": ["legitimatePropertyLease"], "property_owner_edrpou": "02071010",
 "changed_since": "2026-01-01", "changed_until": "2026-08-06", "scan_pages": 40}
```

Only that family publishes the distinction; for every other programme `relatedOrganizations` is
absent, the seller owns what it sells, and `property_owner` matches nothing. There is no silent
fallback to text search — combine with `keyword` if you need one.

### `get_auction`

Everything about one auction. For deadlines alone use `get_auction_timeline`; for the outcome
alone use `get_auction_results` — both return far less.

| Parameter | Type | Notes |
| --- | --- | --- |
| `auction` **(required)** | `string` | Public auction id or 24-character procedure id. |
| `include` | `string[]` | Sections to expand. Default `["items","terms"]`. |

`include` values: `items` (area, cadastral number, address, classification) · `terms`
(starting price, minimal step, guarantee deposit, registration fee, lease duration, conditions,
restrictions) · `documents` · `questions` · `bids` · `awards` · `contracts`.

```jsonc
// Full picture before deciding whether to bid
{"auction": "SPD001-UA-20260806-70620",
 "include": ["items", "terms", "documents", "questions"]}

// Minimal lookup
{"auction": "LRE001-UA-20260708-03894"}
```

### `get_auction_timeline`

The stage clock: what has passed, what is running, what is next, how long is left to bid.

| Parameter | Type | Notes |
| --- | --- | --- |
| `auction` **(required)** | `string` | Public auction id or procedure id. |
| `include_revisions` | `boolean` | Also list archived revisions, showing when the seller amended the terms. Default `false`. |

```jsonc
{"auction": "LRE001-UA-20260708-03894"}
{"auction": "LRE001-UA-20260708-03894", "include_revisions": true}
```

Returns the status with its plain-language meaning, every stage marked
`past`/`current`/`upcoming`, the next deadline, and `bidding.closesAt` / `stillOpen` /
`hoursRemaining`.

### `get_auction_results`

How an auction ended. Works on in-progress auctions too — reports `outcome: "in_progress"`.

| Parameter | Type | Notes |
| --- | --- | --- |
| `auction` **(required)** | `string` | Public auction id or procedure id. No other parameters. |

```jsonc
{"auction": "SPE001-UA-20260729-69703"}
```

Returns `outcome` (`contract_signed` · `winner_determined` · `ended_without_winner` ·
`cancelled_by_seller` · `in_progress`), starting vs final price with `priceUpliftPercent`, bid
counts, `attemptNumber` (>1 means the lot failed to sell before), awards, contracts and
cancellation reasons.

### `get_auction_documents`

Published files with direct public download URLs.

| Parameter | Type | Notes |
| --- | --- | --- |
| `auction` **(required)** | `string` | Public auction id or procedure id. |
| `document_type` | `string` | Case-insensitive substring: `technicalSpecifications`, `illustration`, `notice`, `contractSigned`, `transferAct`, `act`, `cancellationDetails`… |
| `include_attachments_of` | `string[]` | `auction` (default), `awards`, `contracts`, `cancellations`. |
| `include_superseded` | `boolean` | Also list earlier revisions of replaced files. Default `false`. |

```jsonc
// Just the technical specification
{"auction": "SPD001-UA-20260806-70620", "document_type": "technicalSpecifications"}

// The complete paper trail including the signed contract
{"auction": "SPD001-UA-20260806-70620",
 "include_attachments_of": ["auction", "awards", "contracts"]}
```

Documents attached to bids are never published.

### `find_comparable_auctions`

Valuation and risk: what similar lots go for and how often they sell.

| Parameter | Type | Notes |
| --- | --- | --- |
| `auction` **(required)** | `string` | The reference auction. |
| `match_on` | `string[]` | What a comparable must share. Default `["sellingMethod","classification"]`. Add `region` for a local benchmark, `valueRange` to stay within ±50% of the starting price. |
| `concluded_only` | `boolean` | Default `true` — compare only against finished auctions. `false` also shows what is currently on the market. |
| `limit` | `number` | 1–50. Default 15. |
| `scan_pages` | `number` | Widen the sample when `comparableCount` is low. |

```jsonc
// Price benchmark for a land lease (default matching)
{"auction": "LRE001-UA-20260708-03894",
 "match_on": ["sellingMethod", "classification"]}

// What similar assets are on the market right now
{"auction": "SPD001-UA-20260806-70620", "concluded_only": false, "limit": 25}
```

Re-listings of the same lot are excluded, so one asset is not counted twice.

### `track_auction_changes`

Cursor-based monitoring. Repeated calls make forward progress without re-reading the same
activity.

| Parameter | Type | Notes |
| --- | --- | --- |
| `since` | `string` | ISO instant to watch from. Default 24 hours ago. **On follow-up polls pass back the previous response's `nextCursor`.** |
| `until` | `string` | Optional upper bound. |
| `procedure_type` / `status` / `keyword` / `region` | | Narrow the watch to one niche. |
| `limit` | `number` | 1–100. Default 30. |
| `scan_pages` | `number` | Raise when a busy window truncates. |

```jsonc
// First poll of a niche
{"procedure_type": ["landRental"], "region": "Полтав", "limit": 20}

// Follow-up poll using the cursor from the previous response
{"procedure_type": ["landRental"], "region": "Полтав",
 "since": "2026-08-06T06:27:21.880000Z"}
```

Each change carries an `isNew` flag distinguishing brand-new listings from updates. The cursor
is inclusive, so the last auction may repeat once.

### `analyze_auction_market`

Volume and trend questions rather than individual lots.

| Parameter | Type | Notes |
| --- | --- | --- |
| `group_by` | `string` | `direction` (default), `sellingMethod`, `status`, `region`, `seller`, `propertyOwner`. |
| `since` / `until` | `string` | Period to analyse. Omit both for the most recent activity. |
| `procedure_type` / `status` / `region` / `keyword` | | Narrow the population before aggregating. |
| `scan_pages` | `number` | Default 15. Raise for a larger sample. |

```jsonc
// Which regions had the most land activity recently
{"procedure_type": ["landRental", "landSell"], "group_by": "region", "scan_pages": 25}

// How privatisation performed in July 2026
{"procedure_type": ["smallPrivatization"], "since": "2026-07-01", "until": "2026-07-31",
 "group_by": "sellingMethod", "scan_pages": 30}
```

Returns count/total/mean/median/min/max starting prices, a status histogram and
`soldSharePercentOfSettled` — where *settled* means `complete`, `unsuccessful` or `cancelled`
only, so in-flight procedures do not inflate it. These figures describe the scanned window, not
the whole catalogue.

### `list_procedure_types`

The vocabulary the other tools filter on. Cached; no network cost after the first call. Call
this rather than guessing a `procedure_type` — an unrecognised value silently returns nothing.

| Parameter | Type | Notes |
| --- | --- | --- |
| `filter` | `string` | Substring, e.g. `"land"`, `"lease"`, `"privat"`, `"timber"`. |
| `include_status_glossary` | `boolean` | Default `true`. |

```jsonc
{"filter": "land"}
{}
```

Returns 33 programme families with their auction formats, the exact `sellingMethod` values, and
the three-letter auction-id prefix each produces (`LRE` = landRental-english, `SPE` =
smallPrivatization-english…), plus what each of the ten statuses means.

### `get_publication_constraints`

The only forward-looking tool: if a lot is published today, how soon may the auction run.

| Parameter | Type | Notes |
| --- | --- | --- |
| `procedure_type` **(required)** | `string` | A selling method such as `"landRental-english"`. A family name checks every format it supports at once, which is useful for comparing notice periods before choosing one. |

```jsonc
{"procedure_type": "landRental-english"}
{"procedure_type": "smallPrivatization"}
```

Returns `earliestAuctionStart` and `noticeDays`. Computed live, so it is not cached.

## Install

```bash
git clone https://github.com/VladyslavMykhailyshyn/prozorro-sale-mcp-server.git
cd prozorro-sale-mcp-server
npm install
npm run build
```

### Claude Code

```bash
claude mcp add prozorro-sale -- node /absolute/path/to/prozorro-sale-mcp-server/build/index.js
```

### Claude Desktop / any MCP client

```json
{
  "mcpServers": {
    "prozorro-sale": {
      "command": "node",
      "args": ["/absolute/path/to/prozorro-sale-mcp-server/build/index.js"]
    }
  }
}
```

No API key is needed — Prozorro.Sale publishes this data openly.

## Configuration

All optional; see `.env.example`. The variables are read from the process environment, so set
them in your MCP client's `env` block.

| Variable | Default | Purpose |
| --- | --- | --- |
| `PROZORRO_SALE_INSTANCE` | `production` | `production`, `staging` (synthetic data) or `dgf` |
| `PROZORRO_SALE_API_BASE` | — | Full base URL override |
| `PROZORRO_SALE_TIMEOUT_MS` | `45000` | Per-request timeout |
| `PROZORRO_SALE_CACHE_TTL_MS` | `120000` | Feed-page cache lifetime |
| `PROZORRO_SALE_DEFAULT_PAGES` | `8` | Feed pages a search scans by default (100 auctions each) |
| `PROZORRO_SALE_MAX_PAGES` | `40` | Hard ceiling per call |
| `PROZORRO_SALE_MAX_RETRIES` | `2` | Retries on network/5xx errors |

## The one limitation worth understanding

**Prozorro.Sale publishes no full-catalogue search API.** The open-data surface is a change
feed ordered by modification time, plus a per-selling-method feed of the 100 most recent
auctions. There is no "all currently open auctions" endpoint to call.

This server therefore searches by scanning, and it is explicit about what it scanned. Every
discovery tool returns a `scanned` block:

```json
{
  "scanned": {
    "coveredFrom": "2026-08-06T06:12:41.000Z",
    "coveredTo": "now",
    "recordsScanned": 614,
    "truncated": true,
    "note": "Every auction changed in the last 3h was examined (614 records over 3 segments), then the page budget ran out while reaching further back. Auctions last changed before that are NOT fully represented — raise \"scan_pages\" or set an explicit window to go deeper."
  }
}
```

Two consequences to keep in mind:

- **Name the asset programme when you can.** `procedure_type: ["smallPrivatization"]` makes
  the server query the per-type feeds directly, which covers the 100 most recent auctions of
  *each* matching selling method instead of whatever happened to be busiest platform-wide.
- **An empty result means "not in the scanned window", not "does not exist."** Widen with
  `scan_pages`, or pass `changed_since` to scan a specific historical period.

The feed only runs forward in time from a cursor, so the newest records always sit at the end
of a crawl. Starting at an arbitrary "7 days ago" would spend the whole page budget on old
records and never reach the present. Instead the crawler walks a short window ending at now
and then extends backwards in doubling segments while budget remains — so current auctions are
always covered first.

## Data notes

- Bidder and buyer identities are redacted upstream and appear as `[redacted]`.
- Document `downloadUrl` values are public and can be fetched directly.
- Bid amounts become visible only after an auction concludes.
- `auctionId` encodes its programme and publication date: `LRE` (landRental-english) + serial
  + `-UA-` + `20260708` + serial.

## Development

```bash
npm run typecheck   # tsc --noEmit
npm run build       # compile to build/
npm run smoke       # build, then exercise all 10 tools against the live API
```

`scripts/smoke.mjs` is an integration check, not a unit test — it hits production
Prozorro.Sale and prints a one-line summary per tool.

## License

ISC

TDQS

A4.7/5.0

Scored across 10 tools

Disambiguation5/5

Each tool targets a clearly distinct aspect: searching, viewing details, documents, timeline, results, comparables, monitoring, market stats, vocabulary, and seller constraints. Even related tools like get_auction_timeline and get_auction_results are scoped to different questions.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern with lowercase and underscores, using action verbs like search, get, find, track, analyze, list, and get. Sub-resources of an auction are consistently suffixed (get_auction_documents, get_auction_timeline, get_auction_results).

Tool Count5/5

10 tools is well-scoped for a public-asset auction platform. Each tool covers a distinct facet of the domain (search, detail, documents, timeline, results, comparables, monitoring, market analysis, vocabulary, and seller-side planning) without redundancy.

Completeness5/5

The tool surface covers the full read-side lifecycle: discovering auctions, inspecting any aspect (items, terms, documents, bids, awards, contracts), checking deadlines and outcomes, benchmarking prices, monitoring changes over time, aggregating market statistics, and even planning publication dates. No critical dead ends or missing operations for the server's apparent informational and analytical purpose.

Maintenance

ActivityStale
ResponsivenessNo issues