varto

varto

MCP server for Ukrainian public procurement (Prozorro) that exposes tools to search tenders, retrieve tender details and cards, list and read tender documents.

Category
Visit Server

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 --noEmit is 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

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured