Open Data MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Open Data MCPСкільки звернень у Центральному районі?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Open Data MCP
An MCP server and a small reference agent that answer questions about Ukrainian open data (CKAN portal data.gov.ua) from retrieved data and deterministic SQL, not from model memory. Every successful data-backed answer carries machine-readable provenance.
Status: working prototype, not production software. The CKAN adapter, SQL safety layer, MCP server, agent and evaluation are implemented and tested offline against a mocked portal and SYNTHETIC fixtures. A live smoke test against
data.gov.uapassed on 2026-10-18 (one real CSV resource, CSV path only; see Verification status for exactly what was and was not exercised).
The problem
Asking an LLM "how many … were registered in region X in 2023?" invites a confident, unsourced, possibly invented number. Open-data portals publish the real figures, but finding the right resource, understanding its columns and computing an aggregate is tedious. This project lets a model do exactly that through tools, with a hard boundary: numbers come from a SQL query over the real resource, and the answer shows where the data came from and when it was fetched.
Related MCP server: Datos.gob.es-MCP
What you can ask
Questions whose answer is a computation over one tabular resource, in Ukrainian or English:
"Скільки звернень у Центральному районі?" →
COUNTwith a filter"Total planned budget for 2024 across regions?" →
SUMover a DataStore resource"Which region executed the most in 2023?" →
GROUP BY … ORDER BY"Find datasets about air quality" → dataset search
It should refuse or abstain when the data cannot answer (forecasts, opinions, unsupported formats, oversized files, columns that do not exist).
Architecture
flowchart LR
U[User question] --> A[Reference agent<br/>Anthropic Messages API<br/>bounded tool loop]
A <-->|MCP over stdio| S[MCP server<br/>search_datasets · get_schema · query]
S --> C[CKAN client<br/>retries · TTL cache · success check]
S --> F[Safe fetch<br/>HTTPS only · no private IPs · size cap]
C -->|package_search / package_show / datastore_search| P[(data.gov.ua CKAN API)]
F -->|resource URL from validated metadata| P
S --> Q[SQL guard<br/>SQLGlot AST allowlist]
Q --> D[DuckDB in-memory<br/>one table: data<br/>external access disabled]
D --> R[rows + source object]
R --> ASource layout (src/open_data_mcp/): ckan_client.py, safe_fetch.py, query_engine.py,
provenance.py, service.py (pure-Python logic), server.py (MCP), agent.py, cli.py,
fakeportal.py (synthetic fixtures for offline use).
Why MCP, and the three tools
MCP keeps the data layer independent from any one model or UI: the same server works with the bundled agent, Claude Desktop/Code, or any MCP client. All tools are read-only and publish JSON input and output schemas.
Tool | Purpose |
| Dataset ID, title, short description, organization, license, URL, resource formats. |
| Columns/types and how they were obtained: |
| One read-only |
Every tool returns an envelope with ok. On failure: ok: false, an error object with a stable
code (unsafe_sql, unsafe_url, unsupported_resource, resource_too_large, sql_error,
not_found, upstream_error, …) and a hint; no rows, no numbers.
Provenance
build_source() is the only way a source object is made, and assert_provenance() refuses to
return a query result without portal, dataset_id, resource_id and retrieved_at_utc.
"source": {
"portal": "...", "dataset_id": "...", "dataset_title": "...", "dataset_url": "...",
"resource_id": "...", "resource_name": "...", "resource_url": "...",
"retrieved_at_utc": "2026-10-18T20:20:04Z", // when THIS SERVER fetched the data
"metadata_retrieved_at_utc": "...", // metadata may be older (TTL cache)
"dataset_modified_at": null, // the portal's own date; null if not supplied
"resource_modified_at": null, "license": null, "access_method": "file_download"
}retrieved_at_utc is never used as a stand-in for the portal's publication/modification date;
unknown dates stay null. The tool also returns a one-line citation, and the reference agent
appends any citation the model forgot to include, so the source always reaches the user.
How read-only SQL is constrained
AST allowlist (SQLGlot, DuckDB dialect): exactly one statement; root must be
SELECT/UNION/EXCEPT/INTERSECT(CTEs allowed). Every table reference must bedataor a CTE defined in the query: no file paths, URLs,schema.table, or table functions (read_csv,glob,range, …). Functions must be typed SQLGlot functions or on a small allowlist;getenv,read_text,current_setting, … are rejected. Parameters are rejected.Regenerated SQL: the statement DuckDB runs is rendered from the validated AST, with a server-side
LIMIT(default 20, hard max 100).Engine lockdown: data is loaded into an in-memory table first; then
enable_external_access=false, extension auto-loading off andlock_configuration=true. Tests confirm that even rawread_csv('/etc/passwd'),COPY … TO,INSTALLandSETfail at the DuckDB level.Resource limits: 25 MB download cap, 500 000 row cap (refused, never silently truncated, so an aggregate always covers the entire resource), 20 s query timeout, 512 MB memory limit.
No LLM-supplied URLs: the model passes
dataset_id+resource_id; the URL comes from CKAN metadata, must behttps://on port 443, must resolve only to public addresses, and every redirect hop is re-validated.
Supported formats: CKAN DataStore, CSV (UTF-8 or Windows-1251, any delimiter DuckDB sniffs) and
JSON (array of flat records). XLS/XLSX, PDF, archives and other binaries are refused with
unsupported_resource.
Setup
Requires Python ≥ 3.11 (developed and tested on 3.13.16; the suite also passes on 3.11.17) and
uv.
git clone https://github.com/AnatoliiBalakiriev/Open-Data-MCP && cd Open-Data-MCP
uv sync --lockedRun the MCP server
uv run open-data-mcp serve # live portal (default https://data.gov.ua)
uv run open-data-mcp serve --offline-fixtures # SYNTHETIC data, no networkConnect an MCP client (stdio). Example for Claude Code / Claude Desktop style config:
{
"mcpServers": {
"open-data": {
"command": "uv",
"args": ["run", "--directory", "/path/to/Open-Data-MCP", "open-data-mcp", "serve"]
}
}
}(The stdio protocol path is covered by an automated test with the official MCP client; the config above has not been tried in Claude Desktop itself.)
Ask a question through the reference agent
cp .env.example .env # then put ANTHROPIC_API_KEY in .env
uv run open-data-mcp ask "Скільки звернень у Центральному районі?" --offline-fixtures
uv run open-data-mcp ask "Find datasets about air quality" # live portalThe agent uses the Anthropic Messages API (ANTHROPIC_MODEL, default claude-sonnet-5-5, checked
against the official models overview page on 2026-10-18), connects to the server over stdio,
forwards MCP tool definitions as Anthropic tools, and runs a loop capped at 8 model calls
(--max-iterations). Without an API key it prints setup instructions and exits with code 2;
the server, tests and offline evaluation never need a key.
Behind an HTTP proxy that does the DNS resolving (no local DNS), the SSRF guard cannot resolve
resource hosts and refuses them. List hosts you trust explicitly:
OPEN_DATA_TRUSTED_DOWNLOAD_HOSTS=data.gov.ua,opendata.gov.ua (HTTPS is still required).
Tests, lint, evaluation
uv run pytest # offline: mocked CKAN + synthetic fixtures
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv run python evals/run_eval.py # offline evaluation (Level 1)
uv run python scripts/smoke_live.py # live smoke test against data.gov.ua (needs network)Evaluation results
Generated by uv run python evals/run_eval.py --update-readme. Level 1 only: it sends the
gold SQL through the real MCP query tool against a synthetic fixture portal and compares
with answers computed independently in plain Python. It validates the data path (SQL safety,
loading, provenance, refusals). It does not measure how accurately an LLM writes SQL or words
its answer; that is Level 2 (--live, needs an API key, costs money, not run for this README).
Level 1 - tool pipeline (offline, deterministic) — fixture: SYNTHETIC data (not data.gov.ua).
Metric | Passed / Total |
Answer correctness (numeric/categorical) | 20 / 20 |
Provenance present and correct | 20 / 20 |
Correct abstention / refusal | 7 / 7 |
Skipped (agent-level only, need a live LLM): live-forecast, live-opinion.
Failed cases: 0
Details, case list and limitations: evals/README.md.
Verification status
What | Status |
Unit/integration tests, Ruff, mypy, offline evaluation | Run locally (see results above and in your own run). |
MCP server over real stdio subprocess + official MCP client | Tested (automated, offline fixtures). |
GitHub Actions workflow | Written; not yet executed on GitHub. |
Live | Passed on 2026-10-18 (~21:04 UTC): |
Not exercised live | The DataStore path: none of the ~25 sampled resources had |
Live behaviour that shaped the code | The portal sits behind Cloudflare and returns intermittent HTTP 522 (now retried for API calls and downloads); some resources declare their format with Cyrillic look-alike letters ( |
Live agent run with a real Anthropic key | Not run (no key in the build environment). The loop is tested with a scripted model, including over a real stdio subprocess. |
Data source, license, limits
Source: the CKAN Action API of data.gov.ua (
/api/3/action/…). Base URL is configurable (OPEN_DATA_PORTAL_URL), so other CKAN portals can be tried.Licensing: each dataset has its own license; the server returns it in
source.license(ornull). This repository's code is MIT-licensed. The bundled fixtures are synthetic, invented test data, not government data.Freshness: dataset metadata is cached for 300 s by default (
OPEN_DATA_CACHE_TTL_S); data is fetched on every query, never cached. Provenance records both timestamps.Rate limits: unknown for data.gov.ua; the client retries 429/5xx, Cloudflare 52x and transient network errors up to 3 times with exponential backoff, honouring
Retry-After. There is no throttling.Known limitations: messy real-world CSVs (title rows, merged cells) may load as garbage columns; always check
get_schemasample rows; each query re-downloads the resource; no XLSX/Parquet; CSV type inference is DuckDB's best guess; very wide or huge files are refused; DNS answers are validated before the request but the HTTP library resolves again (a determined DNS-rebinding attacker who controls a resource host is not fully excluded); no authentication, so do not expose the server to untrusted networks; one dataset resource per query (no joins across resources).
Security notes
Treat all portal metadata and file contents as untrusted. The LLM never supplies URLs or paths;
SQL is validated by AST, executed in an engine with external access disabled, and bounded in time,
memory, rows and bytes. Tool errors never include response bodies, and secrets are only read from
the environment (.env is git-ignored).
Roadmap
Add one small, licensed real-data snapshot to the evaluation and verify the DataStore path on a portal resource that has it enabled.
Level 2 evaluation of the agent with a recorded run (tracked cost and failure analysis).
XLSX support and a cached, size-bounded download layer with freshness checks.
Available Tools
3 toolsget_schemaBRead-only
Describe a resource's columns and how the schema was obtained (DataStore metadata = authoritative; inferred_from_file = best guess). Also lists the dataset's resources.
| Name | Required | Description | Default |
|---|---|---|---|
| dataset_id | Yes | Dataset id or name from search_datasets. | |
| resource_id | No | Resource id; optional if the dataset has only one. |
Output Schema
| Name | Required | Description |
|---|---|---|
| ok | Yes | |
| note | No | |
| error | No | |
| schema | No | |
| source | No | |
| resources | No | |
| row_count | No | |
| dataset_id | No | |
| dataset_url | No | |
| resource_id | No | |
| sample_rows | No | |
| authoritative | No | False when the schema was only inferred from file contents. |
| dataset_title | No | |
| resource_name | No | |
| schema_source | No | 'datastore_metadata', 'inferred_from_file' or 'unavailable'. |
| sample_columns | No | |
| table_name_in_queries | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, openWorldHint=true, and destructiveHint=false, so safety is covered. The description adds genuinely useful context beyond them: the authoritative-vs-inferred provenance distinction and the fact that it also enumerates the dataset's resources. It stops short of describing pagination or result size behavior.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two compact sentences, each carrying distinct information, with the primary output (columns) front-loaded before the provenance parenthetical and the side-effect note. No filler or restatement of the tool name.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be described, and annotations cover the safety profile; the description still supplies the provenance semantics an agent needs to interpret results. The remaining gap is when-to-use routing relative to the two siblings.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so dataset_id and resource_id are already documented, including the pointer back to search_datasets and the single-resource shortcut. The description adds nothing beyond that baseline, which is the expected 3 when the schema does the work.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description names a specific verb and resource: it describes a resource's columns and explains provenance semantics (DataStore metadata vs inferred_from_file). It also notes a secondary function, listing the dataset's resources. It does not explicitly contrast itself with search_datasets or query, so sibling differentiation is left to inference.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no statement of when to reach for get_schema versus query or search_datasets, nor any prerequisites such as needing a dataset_id from search_datasets first. Usage is only faintly implied by the description of what it returns.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
queryARead-only
Run read-only SQL over one tabular resource (DataStore, CSV or JSON). The SQL always covers the whole resource or the call fails; only the returned rows are capped.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One read-only SELECT over the table `data`; CTEs allowed. | |
| limit | No | Max rows returned (default 20, hard max 100). | |
| dataset_id | Yes | Dataset id or name. | |
| resource_id | Yes | Resource id inside that dataset. |
Output Schema
| Name | Required | Description |
|---|---|---|
| ok | Yes | |
| note | No | |
| rows | No | |
| error | No | |
| source | No | Provenance; always present when ok is true. |
| columns | No | |
| citation | No | One-line source note to include in the answer. |
| row_limit | No | Server-enforced cap on returned rows. |
| truncated | No | |
| scope_note | No | |
| returned_rows | No | |
| rows_in_resource | No | Rows in the whole resource the SQL ran over. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, destructiveHint=false and openWorldHint=true, so safety is covered. The description adds genuinely new behavioral context beyond that: the SQL must cover the whole resource or the call fails, and only returned rows are capped — a meaningful constraint on how queries may be written.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, zero filler. The core capability is front-loaded and the constraint on SQL scope follows immediately, so nothing needs to be read twice.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need no explanation, and all four parameters are fully covered by the schema. The description supplies the key semantic constraints; the only real gap is routing guidance versus the sibling tools.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so sql, limit, dataset_id and resource_id are all documented in the schema (including the SELECT-only rule and the default 20 / hard max 100 limit). The description only reinforces the row-cap concept via 'only the returned rows are capped', adding no syntax or format detail beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description gives a specific verb and resource: running read-only SQL over one tabular resource (DataStore, CSV or JSON). That clearly separates it from a schema-inspection or dataset-search tool, though it never names the siblings search_datasets or get_schema to sharpen the boundary.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied — an agent infers this is the tool for retrieving rows from a resource — but there is no explicit when-to-use statement, no prerequisite (e.g. needing an id from search_datasets/get_schema), and no exclusion rules relative to the siblings.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_datasetsARead-only
Search open-data datasets. Returns IDs, titles, short descriptions, organization, license and dataset URL for each hit.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Max datasets to return (clamped to 20). | |
| query | Yes | Search text, Ukrainian or English. |
Output Schema
| Name | Required | Description |
|---|---|---|
| ok | Yes | |
| error | No | |
| query | No | |
| portal | No | |
| datasets | No | |
| returned | No | |
| limit_applied | No | |
| retrieved_at_utc | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, destructiveHint=false and openWorldHint=true, so the safety profile is fully covered without the description. The description adds only the shape of each hit (IDs, titles, descriptions, organization, license, URL), which largely duplicates the output schema rather than disclosing new behavioral traits such as ranking, rate limits, or the 20-item clamp.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two short sentences, purpose first and return contents second, with no filler or repetition. Every clause earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be re-explained, and annotations carry the safety profile; the schema documents both parameters. The definition is essentially complete, with only minor missing context (e.g., result ordering or truncation behavior) that an agent might want.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%: both 'query' (search text, Ukrainian or English) and 'limit' (clamped to 20) are documented in the schema. The description adds no parameter-level meaning beyond that, so the baseline of 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Search open-data datasets') and enumerates the returned fields, so the agent knows exactly what the tool produces. It does not differentiate itself from the siblings 'query' or 'get_schema', but the purpose itself is unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is only implied by the verb 'Search'; there is no explicit statement of when to prefer this over the sibling 'query' tool or any prerequisite/exclusion. The description never routes the agent among alternatives, leaving that inference to the agent.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
get_schema - First observed
query - First observed
search_datasets
TDQS
Scored across 3 tools
Each tool has a distinct purpose: search_datasets for discovery, get_schema for structural metadata, and query for data extraction. There is no overlap or ambiguity in what each tool does.
search_datasets and get_schema follow a clear verb_noun pattern, but 'query' deviates as a bare verb. The inconsistency is minor and the names remain readable.
Three tools are within the typical well-scoped range (3-15). Each tool is essential for the core open-data workflow: discovery, inspection, and querying.
The surface covers search → schema → query, but there is no direct way to list resources for a dataset or retrieve full dataset metadata. The gap between search_datasets (returns dataset IDs) and get_schema (requires a resource ID) forces workarounds.
Maintenance
Related MCP Connectors
Public Data Ukraine Mcp connects AI agents to real public APIs via MCP. Tools include
Macroeconomic and other official data from 170+ publishers, resolved from natural language with provenance.
Query UK Parliament, elections, crime stats, ONS census data, and national archives
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceProvides seamless access to official Ukrainian government statistics from the State Statistics Service via their SDMX API. It enables users to query bilingual datasets across domains like energy, demographics, and trade using flexible filtering and metadata exploration tools.9 npm50MIT
- AlicenseAqualityCmaintenanceEnables querying and analyzing over 90,000 public datasets from the Spanish Government Open Data Portal (datos.gob.es) using natural language, with tools for search, filtering, metadata access, and SPARQL queries.105MIT
- AlicenseAqualityAmaintenanceEnables AI assistants to search, explore, and query any CKAN open data portal through natural language, making public datasets accessible without requiring knowledge of the portal's API.20596 npm60MIT
- AlicenseAqualityDmaintenanceEnables AI agents to search and analyze Ukraine's national open-data portal (data.gov.ua) using natural language, with tools for finding datasets, inspecting metadata, and retrieving actual data.6MIT