Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Evaluating MCP research quality

Purpose

Every page of this book makes behavioural claims: discovery stays within the UN System Data Commons corpus (Statistical scope), observations exist for countries and supranational geographies (Geographic scope), a facet is chosen by its id, research runs discovery → assessment → retrieval (research rules). The evaluation framework described here exists to make those claims testable. It is deliberately small, and it is not a general evaluation manual for Data Commons.

What we evaluate

LevelWhat it checksWhereStatus
1 ProtocolThe site’s inventory of tools, resources and prompts equals the live server’s; the pages name every tool and carry the live playbookstests/e2e/test_mcp_edge.pyin place
2 Platform invariantsThe scope and provenance claims above, against the live server; the REST graph recipes the advanced page documents, against the live node endpointtests/e2e/test_platform_invariants.py, tests/e2e/test_rest_edge.pyin place
3 Canonical workflowsWalkthroughs A to C execute against the live server at build; the serving tests check the rendered resultsmake build, tests/e2e/test_serving_contract.pyin place
4 BenchmarkA catalog of research questions with per-query expectations, run against the live server, later with rephrasingstests/benchmark/, plugins/benchmark.pycatalog, runner and report in place
5 Endpoint comparisonThe same catalog against two endpoints, compared from the saved artifactsscripts/benchmark_compare.pyin place

Levels 1 to 3 run on every trunk push and on the daily schedule through make test-e2e. The invariants alone run without Docker: uv run pytest -m e2e tests/e2e/test_platform_invariants.py.

Benchmark catalog and expectation types

The catalog, tests/benchmark/catalog.json, records for each research question what a correct outcome looks like. A query carries an id, a category, the user_query a person would ask, a why naming what the query is meant to catch, a run block with the tool and arguments a client would derive from the question, a variables block, and a list of expectations. The variables block lists acceptable UN variables explicitly, or describes them by metadata (acceptable_metadata: population type and constraint values) where a whole family is acceptable, and lists rejected variables each with a reason: the near neighbour that would be a semantic error.

Every expectation carries a type, and the type says what a failure means:

TypeMeaningA failure means
contractBehaviour the book documents as invariantA regression
current_stateA fact about the data loaded today, which a future release may legitimately changeThe coverage changed; review the expectation and the page it comes from
qualityA search expectation, such as an acceptable variable ranking within the top NA search-quality regression, or an expectation to review

A quality expectation that already failed when the catalog was validated carries a dated known_gap. A later run reports it as still failing or as fixed, never as a new regression. Two expectations apply to every query by default: discovery succeeds, and every candidate is a UN variable or UN topic.

The checks an expectation can name, and the layer that can run them:

CheckMeaningLayer
discovery_succeedsThe search call returns a resultdiscovery
scope_purityEvery candidate DCID starts with undata/, every topic with undata/topic/discovery
place_resolves, parent_resolvesA name resolves to the expected shared place DCID and typediscovery
no_variablesDiscovery returns no variables (the geographic-scope case)discovery
acceptable_within_topAn acceptable variable appears within the first N candidatesdiscovery
rejected_not_above_acceptableNo rejected variable ranks above the first acceptable onediscovery
has_data_for_placeThe acceptable variable lists the place in placesWithData, or its metadata carries facets for itdiscovery, metadata
variable_metadataConstraint properties, constraint values, unit, provenance, facet count and the facets’ observation periods read from get_variable_metadata; meaning is checked through metadata, not through DCID spellingmetadata
child_places_return_countriesRetrieval under the parent returns countriesretrieval
outcomeThe kind of answer an agent should reach: data, no_data, clarify or refuseagent

The catalog is validated offline by tests/benchmark/test_catalog.py on every commit: ids, categories, check names and their fields, the rule that acceptable variables are UN variables, and that a rejected variable outside the corpus appears only in the statistical-scope category. The header records the server version and date the expectations were last confirmed against.

Running the benchmark

make benchmark runs every query against MCP_URL (the production server by default) through tests/benchmark/test_discovery.py, one test per query, and writes _benchmark/results.json and _benchmark/report.md. The runner is plugins/benchmark.py, stdlib only. The pipeline runs it on the daily schedule, and on demand from the web UI, as a non-gating job that keeps both files as artifacts. Nothing generated is committed.

Every evaluated expectation ends in one status, and the expectation’s type decides what the status means for the run:

StatusMeaningBlocks the run
passThe check held
failA contract or quality check did not holdcontract only
known_gapA quality check with a dated known_gap still does not hold
known_gap_fixedThe same check now holds: review it and remove the annotation
current_state_changedA current_state check did not hold: new data or changed coverage, review the page
not_evaluatedAn agent-layer check (outcome) this runner cannot judge
errorThe observation the check needed could not be obtainedyes

Rephrasing robustness

A query may carry two or three rephrasings: alternative wordings of the same concept that a client might send, run as fresh requests with the same places and nothing carried over from one phrasing to the next. For each such query the report shows how many phrasings found an acceptable variable within the top N, the rank of the first acceptable candidate per phrasing, how many phrasings ended with an acceptable variable found, with candidates but none acceptable, or with no candidates at all, whether the top candidate agreed across phrasings, the overlap of the first five candidates with the canonical phrasing, and for how many phrasings a rejected variable outranked the acceptable one. Where an expectation is a known gap, the report says whether the gap is stable across phrasings, fixed in all of them, or phrasing-sensitive. This is descriptive evidence, classified as quality, never a contract: exact candidate order need not be identical, the question is whether the intended UN statistical concept stays discoverable and the semantic traps stay controlled under natural changes of wording. Thresholds, if any, come after a baseline has been observed.

Endpoint comparison

The same catalog can be run independently against production and a candidate endpoint (MCP_URL), each run writing its own results.json, and the two artifacts compared without any further call:

make benchmark BENCHMARK_DIR=_benchmark/baseline
MCP_URL=https://candidate/mcp make benchmark BENCHMARK_DIR=_benchmark/candidate
make benchmark-compare BASELINE=_benchmark/baseline/results.json CANDIDATE=_benchmark/candidate/results.json

The comparison classifies each query as unchanged, improved, regressed, mixed, changed or not comparable. Direction is stated only where it is unambiguous: a contract that flips, an acceptable variable that crosses the top-N line, a semantic trap that starts or stops outranking the acceptable candidate, a known gap that closes or reopens. Everything else, a different top candidate among acceptable ones, a reshuffled list, a current-state difference, a duration, is listed for a maintainer to judge. It is a regression-analysis tool, not a competition score: there is no winner and no total. The pipeline offers it as a manual job that takes CANDIDATE_MCP_URL; two endpoints are never run on the schedule.

The report opens with the counts by type and status, then an “attention required” section: contract failures, execution errors, known gaps now fixed, current-state changes, and quality expectations that newly fail. Known gaps still open are listed separately so they read as standing findings, not news. The two gaps found while validating the seed, the under-18 count ranking below percentage variables and the origin-based refugee rate outranking the host-side UNHCR variables, stay visible there until the search changes. They are search-quality observations, not contractual behaviour and not launch blockers.

Query categories

CategoryWhat it catchesSeed
factoidOne indicator for one place; the headline series over its breakdowns2
semantic-discriminationClosely related UN variables that differ in meaning: origin versus host, count versus rate, sex, age, population group, total versus subgroup, a true/false response versus a magnitude4
provenance-facetFacet and provenance behaviour, including a variable whose facets of one provenance differ by periodicity, so that latest need not be annual2
parent-geography-to-countriesA UN region or grouping resolves as the parent and its countries carry data2
geographic-scope-boundaryA place below country level resolves and returns no variables1
statistical-scope-boundaryConcepts the wider graph answers with other publishers’ variables; a wider-graph identifier named outright2
disambiguationA bare name that resolves to the wrong place1
zero-stateA synthetic concept: the search returns nearest neighbours, so absence is judged, not read from an empty list1

Reserved for later phases: temporal, exploratory, data-availability, multi-entity, adversarial and playbook-routing.

Findings recorded while validating the catalog, all dated 2026-09-11 and kept in the catalog header as search-quality observations rather than user rules:

Platform invariants

tests/e2e/test_platform_invariants.py asserts the following against the live server. Each test is marked contract or current_state. In the pipeline the two markers run as two jobs on every trunk push and on the daily schedule: the contract tests gate a release; the current_state tests, which deliberately record known defects of the hosted platform, run in a separate non-gating job, so a change in hosted behaviour is visible the day it happens without blocking a book release.

Deliberately not asserted: whether a retrieval tool serves a variable from outside the corpus when handed its DCID. Today it does; the intended contract is that it must not. Asserting the present behaviour would freeze the wrong thing, so that check waits for the runtime hardening it belongs to.

The REST edge

Inspecting the graph with the REST API documents five recipes against the deployment’s V2 node endpoint. The book never calls that endpoint at build: the examples on the page are static and dated, and tests/e2e/test_rest_edge.py keeps them honest against the live deployment (uv run pytest -m e2e tests/e2e/test_rest_edge.py, stdlib only). The five documented traversals — the UNICEF population family through its series node and its groups, the Age vocabulary behind a constraint, Rwanda’s containment and the LDC grouping’s countries, the UN topic hierarchy from its roots, and the peer group behind UNICEF’s Total population, by Sex with the terminal topic it is paired with, in both directions — are contract: a failure means the page is wrong. The page’s boundary warning rests on safety evidence marked current_state: the same API answers for a base-graph variable, and REST indicator resolution returns base-graph candidates unless scoped. If that evidence stops holding because the deployment becomes UN-scoped by default, the test asks for the warning to be reviewed and softened; it does not fail because contamination disappeared. Pagination behaviour is deliberately not asserted.

Future evaluation layers

Not planned: ranking formulas, browser automation of the website, or judge scoring as the primary signal.