varto
MCP server for Ukrainian public procurement (Prozorro) that exposes tools to search tenders, retrieve tender details and cards, list and read tender documents.
README
Varto
An autonomous analyst for Ukrainian public procurement.
Varto continuously watches Prozorro, Ukraine's open public-procurement system, reads the tender documentation that humans currently read by hand, and produces a reasoned go / no-go recommendation — escalating anything uncertain to a person instead of guessing.
The name is a Ukrainian double meaning: варта — a watch or guard, and чи варто — "is it worth it".
Live: https://varto-ai.vercel.app
Status
Honest snapshot, because a portfolio repository that overstates itself is worse than one that doesn't exist.
| Component | State |
|---|---|
| Prozorro MCP server | ✅ Working — usable today from any MCP client |
| Typed API client + schemas | ✅ Working, validated against recorded live responses |
Document reader (.pdf, .docx) |
✅ Working, with explicit handling for scans and legacy formats |
| Deterministic pre-filter (Gate) | ✅ Working, 7 rules, zero token cost |
| Ingestion pipeline → Postgres | ✅ Working — running in production against the live feed |
| Scheduler | ⚙️ Ships as a migration — Postgres pg_cron calls the endpoint once its two Vault secrets are set (see below) |
| Public feed page | ✅ Working — shows what the Gate let through, with links back to Prozorro |
| LLM analysis chain, verdicts, human review queue | ⏳ Planned |
There are no AI verdicts yet. Nothing in this codebase calls a language model. What runs today is the deterministic half: the crawler, the schema-validated API client, the rule-based Gate, and the page that shows the result. The reasoning stages in the diagram below are designed but not built.
104 tests, clean tsc --noEmit.
What works today: the Prozorro MCP server
A Model Context Protocol server exposing Ukraine's open procurement data as five tools. Point any MCP-compatible client at it and ask about live tenders.
npm install
npm run mcp
To use it from an MCP client, add the server to its configuration. For Claude Desktop this is claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\); for Cursor it is .cursor/mcp.json in the project or ~/.cursor/mcp.json globally. Both use the same shape:
{
"mcpServers": {
"prozorro": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/varto/src/mcp/server.ts"]
}
}
}
Use an absolute path, and restart the client afterwards. The server speaks stdio and needs no environment variables — Prozorro's data is open by law, so there is no API key and no registration.
| Tool | Purpose |
|---|---|
search_tenders |
Walk the Prozorro change feed |
get_tender |
Full tender object by id |
get_tender_card |
Normalised summary: title, CPV, value, buyer, deadline |
list_documents |
A tender's attached documents |
read_document |
Extracted text, or an explicit reason why it could not be read |
Running it locally
npm install
cp .env.example .env # then fill in the Supabase values
npm run dev # http://localhost:3000
Only SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required; everything else has a working default compiled into src/lib/config.ts, documented in .env.example. The MCP server needs none of them.
Database schema lives in supabase/migrations/ and is applied with the Supabase CLI:
npx supabase db push --db-url "$DIRECT_URL"
Every migration is written to be safe to apply twice.
How the scheduler works
Supabase Postgres Vercel (fra1)
┌────────────────────────────┐ ┌────────────────────────────┐
│ pg_cron every 5 minutes │ │ GET /api/cron/scout │
│ │ │ │ requires Bearer secret │
│ └─ pg_net http_get ─────┼── HTTPS ──►│ walks the change feed │
│ URL + secret read │ │ applies the Gate │
│ from Vault │ │ writes tenders + reasons │
│ │◄── SQL ────┼─────────┘ │
└────────────────────────────┘ └────────────────────────────┘
The schedule lives in Postgres rather than in vercel.json because Vercel's Hobby plan caps cron jobs at one run per day, which is far too slow to keep up with the change feed. pg_cron has minute granularity, pg_net makes the outbound HTTPS call, and Supabase is already a hard dependency — so this adds no new service.
The endpoint is a plain authenticated GET. It refuses to run without CRON_SECRET set and refuses to run when the bearer token does not match: a crawler that spends API quota and writes to a database must fail closed, not open.
Neither the URL nor the secret appears in the migration — supabase/migrations/ is public. Both live in Supabase Vault and are read at call time:
select vault.create_secret('https://<your-deployment>/api/cron/scout', 'scout_endpoint_url');
select vault.create_secret('<the same CRON_SECRET that is set on Vercel>', 'scout_cron_secret');
Create those two secrets first, then apply supabase/migrations/0003_scout_schedule.sql with db push. Applied before the secrets exist, the job will run and fail loudly every five minutes — deliberately, because a schedule that quietly does nothing is worse than one that complains.
The cadence and the page size are one decision, not two. The feed carries ~60,700 modifications a day, so SCOUT_PAGE_LIMIT × ticks-per-day has to stay above that: 500 every five minutes is 144,000 a day, a 2.4x margin. The same page every ten minutes would be 72,000 — nominally more than the feed produces, but a thin enough margin that a modest tick failure rate would push the cursor backwards. Change one number and recompute the other.
To confirm the job is actually firing — not merely that it exists:
select status, return_message, start_time from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'scout-feed-poll')
order by start_time desc limit 5;
select status_code, left(content::text, 300), created
from net._http_response order by created desc limit 5;
A 401 there means the secret in Vault and the one on Vercel have drifted apart.
Why the function runs in Frankfurt
Vercel places functions in Washington, D.C. (iad1) by default. Prozorro is in Ukraine and the database is in eu-central-1, so every feed read and every write crossed the Atlantic twice; a full page of the change feed exceeded the 60-second function limit and was killed mid-loop, which meant the cursor never advanced and the next run repeated the same page forever. vercel.json pins the function to fra1, next to the database. The page size is SCOUT_PAGE_LIMIT — an environment variable rather than a constant, precisely because its correct value depends on where the code runs.
What the data actually looks like
Every design decision below came from measuring the live API, not from assuming. Three assumptions turned out to be wrong, and each would have quietly broken the product.
PDF is not the format of Ukrainian tender documentation. A census of 400 documents across 99 tenders:
| Format | Share of all documents |
|---|---|
.docx |
49.3% |
.p7s (detached signatures) |
29.0% |
.doc |
10.0% |
application/octet-stream |
6.0% |
.pdf |
3.5% |
Narrowed to documents actually tagged as tender documentation, 94% are .docx/.doc and effectively none are PDF. A PDF-only reader would have been blind to almost everything that matters. Varto reads .docx and .pdf, flags legacy .doc as needing a human, and treats .p7s as a signature rather than a document.
Prozorro reports Content-Type: text/plain for every file, whatever it actually is. File type is therefore detected from content magic bytes, never from the response header and never from the filename.
Scanned PDFs with no text layer exist but are rare — 1 in 146 sampled documents. They are detected explicitly and surfaced as "needs a human" rather than silently returning empty text, which would invite a model to hallucinate over nothing.
The change feed is bigger than it looks. Roughly 60,700 modifications a day pass through it. Asking Prozorro for the full record of each one was never going to keep up; requesting the status inline with the feed discards about 85% of items before any per-item fetch, which is what makes a single small function able to out-run the feed.
Architecture
The pipeline is ordered so the cheapest stages discard the most work. Three of them use no language model at all.
Prozorro change feed
│
Scout plain code, 0 tokens ─→ local index ← built
Gate plain code, 0 tokens ─→ rejected, with the reason recorded ← built
Triage small model ─→ rejected, with the reason recorded
DocPicker plain code, 0 tokens ─→ picks 2–4 documents out of a dozen
Locator small model ─→ picks the sections worth reading
Analyst long-context model ─→ requirements + verbatim citations
Risk ×2 two different families ─→ disagreement ⇒ escalate to a human
Arbiter judgement model ─→ go / no-go + confidence
│
└─ uncertain, over budget, or a red flag ⇒ human review queue
└─ every human decision becomes a labelled eval example
Three decisions worth calling out:
Two risk assessments from different model families. A model that is wrong is usually confident, so self-reported confidence is a weak signal. Two independent families disagreeing is a cheap, honest indicator that a case is genuinely hard — and it routes to a human automatically.
Nothing is discarded silently. Every stage records why it decided what it decided. A rejected tender keeps the rule that rejected it and a human-readable detail; a tender that could not be read at all is recorded as a failure rather than skipped, because the cursor moves on regardless and the trace has to outlive the logs.
The agent never submits a bid. It analyses and recommends; submitting is a human action. That is a product decision, not a missing feature.
Tech stack
TypeScript · Next.js on Vercel · Supabase (Postgres, pg_cron, pg_net, Vault) · OpenRouter · Model Context Protocol · Vitest · Zod
Model selection lives in configuration, never hardcoded, and every model was verified with a live call before being trusted — two of the first four candidates turned out to be unusable, including one model ID that does not exist at all.
Development
npm install
npm test # 104 tests
npm run typecheck # tsc --noEmit, must be clean
npm run build # next build
npm run mcp # start the MCP server over stdio
Three conventions this repository holds to:
- Tests are checked for whether they can actually fail. A test that passes identically against code where the feature is absent is treated as a defect, not as coverage. Several were found and replaced.
- Passing tests are not evidence that the code compiles. Vitest strips types without checking them, so
tsc --noEmitis a separate gate on every change. - Comments carry measurements, and stale measurements are bugs. More than one defect here traced back to a comment that had quietly stopped being true.
Unit tests never touch the network. The reconnaissance scripts under scripts/ do, and are marked as not being tests.
Interface text is Ukrainian; code, identifiers and documentation are English.
Licence
MIT
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。