Generic MCP server for SDMX registries built with FastMCP. Set SDMX_BASE_URL to your SDMX REST API and configure registry-specific policies from the included examples.
This project lets an LLM client do a full guided data journey:
- pick agency
- discover by subject, location, or time
- select a dataflow through guided indicator-first planning
- inspect dimensions and codelists
- rank candidate indicators
- query data with CSV transport for SDMX
/datapayloads
server.py: MCP tools and SDMX integration logic.query_dimension_policy.example.json: example subject/location/time query-dimension policy.discovery_policy.example.json: example discovery ranking policy for stopwords and topic-to-flow hints.scripts/mcp_fidelity_sweep.py: exhaustive MCP-vs-API comparison. No LLM; proves the pipe is faithful.scripts/make_area_list.py: builds an area allowlist from a registry codelist, for registries where no pattern separates countries from aggregates (M49).scripts/sdmx_eval_build_cases_from_sweep.py: stratified agent-eval cases drawn from the sweep's verified ground truth.scripts/agent_test_rig.py: direct tool-call harness that simulates an agent workflow.scripts/sdmx_eval_runner.py: generic SDMX eval harness for case generation, provider execution, and grading.scripts/sdmx_eval_model_sweep.py: runs one manifest across many models and tabulates accuracy against measured cost, per stratum. Resumable.scripts/sdmx_eval_refresh_ground_truth.py: re-reads expected values from the live registry. Run it immediately before grading, every time.scripts/sdmx_eval_normalise_claims.py: applies claim recovery to response files already collected, so a harness fix can be applied retroactively.scripts/sdmx_eval_consolidate.py: gathers runs from several directories into one, refusing to pair grades with responses from a different run.scripts/sdmx_eval_stats.py: per-model accuracy, Wilson intervals, measured cost, token profile and tool-error classification.scripts/sdmx_eval_pooled.py: per-stratum pass rates and the spread across models — the difficulty gradient.scripts/sdmx_eval_variance.py: how far a figure moves across identical repeat runs, and which serving host answered.scripts/sdmx_eval_config.example.json: example config for deterministic case generation and provider runs.scripts/sdmx_eval_provider_anthropic.py: Anthropic Messages API adapter using Anthropic's MCP connector.scripts/sdmx_eval_provider_template.py: stdin/stdout adapter template for wiring any LLM+MCP stack into the eval harness.scripts/list_theme_prefixes.py: helper to inspect dataflow ID prefixes.theme_prefixes_domain.csv: optional example prefix-to-domain mapping for grouping. SetSDMX_THEME_PREFIX_CSVto use it.paper/: source for the written report and the deck, built from one markdown file so the two cannot disagree. Seepaper/README.md.
git clone https://github.com/<you>/sdmx_mcp.git
cd sdmx_mcp
UV_CACHE_DIR="$PWD/.uv-cache" uv sync
UV_CACHE_DIR="$PWD/.uv-cache" uv run fastmcp run server.py --transport stdioRuntime config is loaded from .env automatically when present.
Main ships only example policy files. Copy query_dimension_policy.example.json to query_dimension_policy.json, copy discovery_policy.example.json to discovery_policy.json, or set the corresponding environment variables when you want active registry-specific policies.
HTTP mode (local):
UV_CACHE_DIR="$PWD/.uv-cache" uv run fastmcp run server.py --transport streamable-http --host 127.0.0.1 --port 8000 --path /mcpTest endpoint:
curl -sS -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Build and run:
docker build -t sdmx-mcp:latest .
docker run --rm -p 8000:8000 sdmx-mcp:latestDocker installs dependencies with uv sync --frozen --no-dev --no-cache, so builds use uv.lock rather than resolving floating dependency versions from PyPI.
Container command uses:
fastmcp run server.py --transport streamable-http --path /mcp
fly auth login
fly deployFly deploys use the same Dockerfile and locked dependency install. The app may intentionally autostop depending on fly.toml settings.
Generic main intentionally does not auto-deploy on push. The included .github/workflows/fly-deploy.yml workflow is manual-only (workflow_dispatch) so downstream branches can opt into deployment after setting their own Fly app/config.
Sanity check:
curl -sS --max-time 20 -X POST https://<your-app>.fly.dev/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Tools and resources are defined in server.py.
The server intentionally exposes only guided discovery resources. Earlier metadata/data resources were removed because some MCP clients expose all resources directly to users and then fail or create redundant tool paths.
Current resources:
sdmx://discover/subject(Discover by Subject)sdmx://discover/location(Discover by Location)sdmx://discover/time(Discover by Time)
Use resources for:
- giving the user an intentional entry point
- instructing the client to call
guided_discover(...) - showing example prompts and configured policy sources
Use tools for:
- query planning
- metadata discovery
- codelist lookup
- hierarchy inspection
- hierarchy resolution and fallback planning
- shaped retrieval
- comparison, aggregation, or other procedural work
Discovery mode is the user-facing entry path only. The backend still resolves subject, location, and time according to the configured query policy.
Configuration is split into two policy files:
query_dimension_policy.json: what dimensions matter and how to resolve themdiscovery_policy.json: how natural-language discovery is scored and routed
This separation is intentional. A registry owner can configure the SDMX semantics independently from search/ranking heuristics.
The generic main branch includes .example.json versions only. Active policy files should be provided by downstream branches or deployment environment variables.
| variable | default | effect |
|---|---|---|
SDMX_ENFORCE_SCOPE |
false |
Reject an out-of-scope flowRef server-side rather than merely discouraging it in a prompt. Enforced at every data and metadata entry point, so it cannot be bypassed by skipping discovery. |
SDMX_DATAFLOW_ID_ALLOW_REGEX |
unset | Restrict the server to matching flows, e.g. ^GLOBAL_DATAFLOW$. |
SDMX_INDICATOR_FLOW_LIMIT |
3 |
Flows listed per candidate by find_indicator_candidates. 0 disables trimming. |
Pinning an eval deployment to a single flow is the clearest use of the first two. Scoped to GLOBAL_DATAFLOW, one find_indicator_candidates call returns ~1,300 tokens instead of ~16,000, recommends the right flow, and an agent cannot wander into a flow that lacks the data.
SDMX_INDICATOR_FLOW_LIMIT exists because these flows share one INDICATOR codelist, so codelist membership matches every indicator against every flow. The untrimmed dataflows array came back byte-identical for all candidates and made up 95% of a 57KB payload. The trimmed response adds dataflowsTotal and a note that membership is not evidence the flow holds data — prefer recommendedFlowRef.
This is a payload-shape issue, not a metadata gap. Only 4 of ~42 UNICEF flows declare an INDICATOR content constraint, so better upstream metadata would make the list accurate; it would not stop it being emitted once per candidate.
A throttled registry and a flow with no matching observations are different answers, and callers can tell them apart:
| upstream | status |
retryable |
|---|---|---|
| 429 | rate_limited |
true |
| 5xx, 408, 425 | upstream_unavailable |
true |
| 404, 400, other | unresolved_from_official_flows |
false |
Retryable responses carry httpStatus and a message that explicitly denies absence of data, so an agent reports "could not complete, retry" rather than "no data exists". Sustained request streams against a public registry will hit 429 — pace them.
Warning
A model will not act on retryable by itself. In testing, agents received
status: "rate_limited", retryable: true and the message "this does not mean
the data is absent", and answered value: null regardless — collapsing a
transport failure back into "no data". For a user that is the worst outcome: a
transient 429 surfacing as a confident "no data for Liechtenstein".
The data path labels this correctly. The discovery path did not, until
October 2026: find_indicator_candidates sweeps every scoped flow and skipped
the ones it could not read, which is right when one flow of many is broken. In
a total registry outage every fetch failed, every failure was skipped, and the
tool returned an empty list with isError: false. A model asked for "total
population" was told, in effect, that the registry holds no such indicator —
and abstained. Discovery now raises when nothing was readable and the cause
was the transport, while still skipping a single unreadable flow.
Beyond that, any client built on this MCP should do both:
- Retry in the client, with backoff, before the model sees the result.
scripts/sdmx_eval_provider_anthropic.pydoes this viathrottle_retriesandthrottle_backoff_seconds. Where tool calls run inside a hosted connector the individual call cannot be retried, so reissue the request. - Say it in the system prompt — never report null for a retryable status — as a second line of defence for when a retry budget is exhausted.
Note this also affects anyone else querying the same public registry: your traffic and theirs share the limit, so a quiet deployment can still be throttled by someone else's bulk job.
The query-dimension policy defines the retrieval dimensions and their resolution order. The default policy defines:
subjectlocationtime
Time is special and resolves against the TIME_PERIOD dimension directly. Other important query dimensions are expected to point at SDMX codelists or hierarchical codelists through policy configuration.
Override it with either:
SDMX_QUERY_DIMENSION_POLICY_JSONSDMX_QUERY_DIMENSION_POLICY_FILESDMX_DEFAULT_LAST_N_OBSERVATIONS
An example file is included at query_dimension_policy.example.json.
Important
Writing this policy is a deployment step, not an optimisation. Which
dimension carries the subject role, which the geography, which the time, and
which tokens to ignore when scoring a query, are properties of your
registry and of the questions your office actually receives. They cannot ship
with useful universal defaults, which is why the repository carries an example
rather than a working file — and why main does not carry one at all.
The server does not fail without one — it falls back to a built-in default, and that is the problem. The default declares the three roles with no dimension and no codelist bound to any of them, and zero flow topic hints. So an unconfigured deployment starts, answers, and quietly operates without the resolution layer, which is the part worth the most: withholding those tools in a controlled ablation cost the best model tested 45 accuracy points and the next 95. Nothing errors. You simply get much worse answers than the server is capable of.
It is a small piece of work — a single JSON file, an afternoon with somebody
who knows the registry's conventions — but it is not optional, and a
deployment that skips it will conclude the tooling does not work rather than
that it was never configured. Plan it alongside pointing SDMX_BASE_URL at
your endpoint and declaring which dataflows are in scope.
Example policy:
{
"auto_apply_total": {
"enabled": false,
"dimensions": ["SEX", "WEALTH_QUINTILE", "RESIDENCE"],
"never_apply": ["INDICATOR", "REF_AREA", "AGE"]
},
"default_query_dimensions": [
{
"name": "subject",
"role": "subject",
"required_for_retrieval": true,
"priority": 1,
"discovery_label": "Discover by Subject",
"discovery_description": "Start with the phenomenon, metric, or topic and let the system find the best indicator and flow.",
"example_prompts": [
"Tell me about an indicator in a region.",
"Show me education coverage for a country group."
],
"preferred_sources": [
{"type": "codelist", "id": "AGENCY/CL_INDICATOR/1.0"}
]
},
{
"name": "location",
"role": "geography",
"required_for_retrieval": true,
"priority": 2,
"discovery_label": "Discover by Location",
"discovery_description": "Start with a country, region, or grouping and let the system resolve the right flow and indicator.",
"example_prompts": [
"Tell me about an indicator in a region.",
"Compare an indicator across a country group."
],
"preferred_sources": [
{"type": "codelist", "id": "AGENCY/CL_REF_AREA/1.0"},
{"type": "hierarchical_codelist", "id": "AGENCY/REPORTING_REGIONS/1.0"}
],
"allow_hierarchy_resolution": true,
"allow_member_expansion": true
},
{
"name": "time",
"role": "time",
"required_for_retrieval": true,
"priority": 3,
"discovery_label": "Discover by Time",
"discovery_description": "Start with the period or trend you want and let the system resolve the right subject, flow, and location slice.",
"example_prompts": [
"What changed over time for an indicator in a region?",
"Show the latest values for this topic."
]
}
]
}Resolution order follows policy priority, not hardcoded dimension names. Non-time dimensions are resolved through the configured code-list or hierarchy sources, and policy source IDs should use the SDMX reference form agency/id/version where applicable. time is resolved against TIME_PERIOD.
auto_apply_total lets a registry owner opt in to filling omitted dimensions with _T. It is disabled by default. When enabled, the server only applies _T to listed dimensions that are present in the selected flow and whose codelist actually contains _T; explicit user filters always win. never_apply blocks dimensions even if they are also listed in dimensions. TIME_PERIOD is always treated as special query-parameter time and never receives _T.
The same policy can also drive three user-facing discovery resources:
Discover by SubjectDiscover by LocationDiscover by Time
Optional policy fields for each query dimension:
discovery_enableddiscovery_labeldiscovery_descriptionexample_prompts
Discovery mode is the user-facing entry path only. Final query construction still honors policy priority.
find_indicator_candidates / search_indicators annotate each candidate with
unit (and unitMultiplier where the registry sets one):
CME_MRM0 Neonatal mortality rate unit='Deaths per 1,000 live births'
CME_TMM0 Neonatal deaths unit='Number of deaths'
This is the strongest signal for separating sibling indicators whose labels sit close together — a rate from a count, a percentage from a number affected. Without it an agent has to choose an indicator, query it, and only then discover from the observation that it picked the wrong one.
UNIT_MEASURE is an observation-level attribute in SDMX, not a dimension, so it
cannot be read from structure. It is resolved with one bulk data probe per flow,
cached for 24h, and is best-effort: a failed probe omits the unit rather than
failing discovery. Pass includeUnits=false to skip it.
Set SDMX_UNIT_PROBE_AREAS to a few well-populated area codes to keep the probe
small (measured: 27KB/90 rows versus 1.48MB/5152 rows unrestricted, same
coverage). Any indicator those areas do not cover is re-probed unrestricted, so
correctness does not depend on the list. SDMX_DISCOVERY_UNITS=false disables
annotation entirely.
What measurement shows. A controlled A/B on 75 ambiguous-phrasing cases —
two servers identical but for SDMX_DISCOVERY_UNITS, same cases, same models —
found no statistically significant effect either way:
| model | units off | units on | exact McNemar |
|---|---|---|---|
| claude-haiku-4.5-class | 74/75 | 75/75 | p = 1.00 |
| a weaker cheap model | 54/75 | 49/75 | p = 0.36 |
The weaker model got worse, and 11 of its 12 regressions were abstentions —
it answered value: null on cases it had previously answered correctly. The
extra field appears to give a model with weak tool-use something more to
second-guess.
So units are justified on the grounds that they expose real metadata the agent
could otherwise only obtain by querying, not on a demonstrated accuracy gain.
If you deploy for a model that abstains readily, measure before assuming this
helps, and use SDMX_DISCOVERY_UNITS=false if it does not.
The discovery ranking policy controls generic text-scoring behavior. By default the server auto-loads discovery_policy.json.
Override it with either:
SDMX_DISCOVERY_POLICY_JSONSDMX_DISCOVERY_POLICY_FILE
An example file is included at discovery_policy.example.json.
Supported fields:
query_stopwords: generic prompt words ignored during candidate scoring, such astell,show, orlevelflow_topic_hints: registry-specific term groups that prefer matching dataflow IDs, such as nutrition terms preferringNUTRITION
Example:
{
"query_stopwords": ["tell", "show", "level", "latest", "table"],
"flow_topic_hints": [
{
"terms": ["bmi", "nutrition", "obesity", "stunting", "wasting"],
"preferred_flow_markers": ["NUTRITION"]
},
{
"terms": ["education", "learning", "school"],
"preferred_flow_markers": ["EDUCATION"]
}
]
}preferred_flow_markers are matched against flow IDs and flow refs, so a marker like NUTRITION matches AGENCY/NUTRITION/1.0.
All SDMX /data calls are fetched as CSV for efficiency and simpler downstream parsing. This applies to query_data, validate_query_scope, resolve_and_query_data, and guided_discover.
Structure and discovery tools still return JSON objects because SDMX structures, codelists, and hierarchy metadata are JSON-shaped in this server.
If a caller requests a non-CSV data format, the response records the requested format and the CSV override in notes.
Time inputs accepted by the query policy can be:
- an explicit range like
2004:2024, which becomesstartPeriodandendPeriod - a single period like
2024, which becomesstartPeriod=2024&endPeriod=2024 latest, which useslastNObservations=1all,trend,series,chart,graph, ortable, which omit both time parameters andlastNObservations
list_agencies(limit=50)
- Purpose: list agencies discovered from SDMX dataflows.
- Returns:
[{id,name,description}].
search_dataflows(query, limit=10)
- Purpose: keyword search across dataflow ID/name/description.
- Returns: scored matches with
flowRefandthemeHint.
list_dataflows_grouped(query=None, prefixMap=None, limitPerTheme=50)
- Purpose: group dataflows by inferred/curated theme prefix.
- Returns:
[{themeCode,themeLabel,flows:[...]}].
get_default_theme_prefix_map()
- Purpose: return built-in prefix-to-theme map.
list_theme_prefixes(limit=50)
- Purpose: scan dataflows and report common prefixes with counts/examples.
list_theme_prefix_conflicts(limit=100)
- Purpose: show prefixes mapping to multiple domains in
theme_prefixes_domain.csv.
describe_flow(flowRef)
- Purpose: summary of a flow plus parsed dimension metadata.
- Returns:
{id,agencyID,version,name,description,dimensions}.
list_dimensions(flowRef)
- Purpose: ordered dimensions with concept/codelist references.
list_codes(flowRef, dimension, query=None, limit=50, includeHierarchyHints=True)
- Purpose: list codes for one dimension, optional text filter.
- Behavior: when
includeHierarchyHints=True, results are enriched with lightweight structure hints such askind(leaf,aggregate, orunknown),expandable,hasChildren,memberCount,hierarchySource,childrenPreview,parentCode, andhierarchyMatches. - Design: this makes
list_codesthe main code-discovery surface, whileexpand_dimension_groupremains the explicit expansion step.
search_reference_candidates(flowRef, query, dimension=None, limit=20)
- Purpose: search group-like reference structures across ordinary codelists, codes, and hierarchical codelists.
- Use when a user query implies a region, category, country group, or other aggregate concept, especially when reference data debt means the grouping lives in a normal codelist rather than a formal hierarchy.
find_indicator_candidates(query, flowRef=None, limit=10, flowQuery=None, flowLimit=200)
- Purpose: rank
INDICATORcodes by relevance to user text; whenflowRefis omitted, scan scoped flows.
search_indicators(query, flowRef=None, limit=10, flowQuery=None, flowLimit=200)
- Purpose: alias of
find_indicator_candidates.
guided_discover(question, discoveryMode, flowQuery=None, indicatorLimit=10, flowLimit=200, labels='name', resultShape='topline_summary')
- Purpose: run the full guided discovery workflow from a user-facing entry mode of
subject,location, ortime. - Behavior: uses the chosen discovery mode as the visible entry path, then resolves subject/location/time according to the configured backend policy before validating and executing the query.
get_flow_structure(flowRef)
- Purpose: fetch/cache structure payload. By default, structure requests use
references=descendantsto retrieve the DSD, codelists, and concepts without unrelated registry artifacts. Override withSDMX_STRUCTURE_REFERENCESif a registry requires another SDMX references mode.
build_key(flowRef, selections)
- Purpose: build SDMX key from dimension selections.
- Notes: supports list/comma syntax for multi-code segments.
list_hierarchical_codelists(agency=None, query=None, limit=50)
- Purpose: list hierarchical codelists available for an agency.
describe_hierarchical_codelist(hierarchyRef)
- Purpose: describe one hierarchical codelist and expose root codes.
resolve_hierarchy(flowRef, dimension, code)
- Purpose: choose the best matching hierarchy for a flow dimension/code.
- Returns:
statusofresolved,ambiguous, orunresolved. - Behavior: if more than one hierarchy plausibly matches, the MCP returns ambiguity instead of guessing.
expand_dimension_group(flowRef, dimension, code)
- Purpose: expand an aggregate dimension code into descendant/member codes using the resolved hierarchy.
resolve_dimension_fallback(flowRef, dimension, code, filters=None, startPeriod=None, endPeriod=None, lastNObservations=None, labels=None)
- Purpose: validate an aggregate dimension query and, if unresolved, return a hierarchy-based retry plan using member codes.
plan_query(flowRef, key=None, filters=None, startPeriod=None, endPeriod=None, lastNObservations=None, format='csv', labels='name', resultShape=None)
- Purpose: resolve a query into a concrete SDMX URL before execution and show wildcard dimensions that can still split the result.
validate_query_scope(flowRef, key=None, filters=None, startPeriod=None, endPeriod=None, lastNObservations=None, labels='name')
- Purpose: preflight whether a concrete SDMX query resolves before any narrative answer is attempted.
- Returns: structured source-bound status with
status,sourceScope,provenance, optionalerror, andassistant_guidance.
query_data(flowRef, key=None, startPeriod=None, endPeriod=None, format='csv', labels='name', maxObs=50000, filters=None, lastNObservations=None, resultShape=None)
labels='both'returns code and label together in SDMX CSV, which reads well in demos.- Purpose: run data query with bounded extraction guardrails.
- Key behaviors:
- defaults to
lastNObservations=1when no explicitstartPeriodandendPeriodare provided andSDMX_DEFAULT_LAST_N_OBSERVATIONS=true - when
SDMX_DEFAULT_LAST_N_OBSERVATIONS=false, unqualified requests default to all time periods unless the caller explicitly asks for latest data or provides a range - does not inject
startPeriod,endPeriod, orlastNObservationsfor full-series requests such asresultShape='compact_series'orresultShape='topline_summary' - supports
filtersto auto-build key from dimension order - leaves unspecified dimensions as empty key segments (
.wildcard) - always fetches SDMX
/datapayloads as CSV for efficiency and simpler downstream parsing - returns
raw_csvfor data queries; structure and discovery tools still return JSON objects - if a caller passes a non-CSV
format, the response notes record the requested format and the CSV override - supports
resultShapevaluescompact_series,latest_single_value,latest_by_ref_area, andtopline_summary - defaults to
labels='name'for CSV readability - returns explicit
statusof eitherresolvedorunresolved_from_official_flows - when
resultShape='latest_single_value', returns explicit non-answer states likeno_observations,no_value_column, ornot_a_single_valueinstead of silently picking a row - always includes
sourceScope,provenance, andassistant_guidance - parses SDMX XML error payloads into structured
error.message - on unresolved queries, callers should stop and report the failed official query instead of supplementing with external facts
- defaults to
resolve_and_query_data(flowRef, filters, startPeriod=None, endPeriod=None, lastNObservations=None, labels='name', resultShape='latest_single_value')
- Purpose: high-level helper for common user questions.
- Behavior: validates the direct query first, then attempts a single hierarchy-based member fallback when an aggregate code does not resolve, and finally returns a shaped result.
get_single_observation(flowRef, filters=None, subject=None, location=None, time='latest', extraFilters=None, verbose=False)
- Purpose: compact exact-value tool for one indicator, one location, and one period/latest value.
- Returns:
status,value,period,unit(resolved label),refArea,refAreaName,indicator,indicatorName, and compactsource; no raw CSV. verbose=False(default): compact — strips filter-dimension fields beyond core context (e.g.sex,age).verbose=True: includes all resolved coded filter dimensions and their labels.
get_indicator_table(flowRef, filters=None, subject=None, locations=None, location=None, time='latest', extraFilters=None, maxRows=200, verbose=False)
- Purpose: compact latest-by-location table for multi-country, group, comparison, ranking, or table requests.
- Each row:
refArea,refAreaName,INDICATOR,indicatorName,value,period,unit(resolved label),rowCountAtLatestPeriod. verbose=False(default): coded dimensions constant across all rows (e.g.SEX=_Teverywhere) are dropped — they carry no information for this result set. Provenance attributes (UNIT_MULTIPLIER, OBS_CONF, …) are dropped exceptDATA_SOURCE, which is always kept.verbose=True: all coded dimensions (constant or varying) and all non-empty attributes, each with a companion*Namelabel field.- Single-location and multi-location queries produce identical row schemas for the same flow and mode.
get_time_series(flowRef, filters=None, subject=None, location=None, time='all', extraFilters=None, maxObservations=500, verbose=False)
- Purpose: compact period/value series for trend, change-over-time, chart, or historical requests.
- Each point:
period,value,unit(resolved label). Top-level context:refArea,refAreaName,indicator,indicatorName. verbose=False(default): compact — omits injected filter-dimension fields beyond core context.verbose=True: includes all resolved coded filter dimensions.
All three compact tools resolve codes to human-readable labels from the flow's DSD rather than relying on the SDMX endpoint's labels=name parameter. This works correctly when components such as UNIT_MEASURE live in attributeList rather than dimensionList, applies the same resolution logic to every coded component, and falls back to the raw code string on any resolution failure (never null). Codelists are discovered from the DSD structure — no codelist IDs are hardcoded.
scripts/mcp_fidelity_sweep.py answers one question exhaustively: does the MCP return what a direct SDMX API call returns?
It deliberately does not call an LLM. That question is a property of deterministic software, and a model in the loop can only add false failures (it picks the wrong indicator) and false passes (it is right while the MCP drops a dimension). Agent capability is a separate, sampled experiment — see Agent Eval.
Ground truth is one bulk CSV per flow, so the API side costs about one request rather than one per observation.
# full sweep, ~3 minutes
python3 scripts/mcp_fidelity_sweep.py --rate 2
# exercise the compact projection instead of the raw data path
python3 scripts/mcp_fidelity_sweep.py --mode series --limit 2000 --rate 2 --resumeTwo modes, because call count is the binding constraint — the registry throttles hard:
| mode | calls | covers |
|---|---|---|
bulk (default) |
~350 | raw data path, one query_data per indicator |
series |
~79,000 | the compact projection get_time_series applies |
Output is a CSV with one row per observation: the flow's dimensions, year, mcp_value, api_value, both units, and a verdict of match / MISMATCH / MISSING_IN_MCP / EXTRA_IN_MCP / ERROR. Exit code is 1 on any mismatch, so CI can gate on it. --resume checkpoints.
Result against UNICEF's GLOBAL_DATAFLOW for countries, annual 2015–2024: 466,360 / 466,360 observations match (100.0000%) across 350 indicators and 235 countries.
A country-level sweep must exclude regional aggregates, and no rule does that reliably across registries:
| rule | against UNICEF CL_COUNTRY (459 codes, 235 countries) |
|---|---|
| ISO3 shape (default) | exact here. Useless under M49, where 004 is Afghanistan and 002 is Africa |
| leaf of hierarchy | wrong here — 441 of 459 are leaves, the hierarchy being only 18 parents deep |
| has a parent | inverted between registries: here aggregates carry parents, under M49 countries do |
Membership is authoritative; the pattern is an optional extra filter.
# M49: select by membership, disable the shape check
--area-codelist AGENCY/CL_COUNTRY_ONLY/latest --area-pattern ''
--area-list m49_countries.txt --area-pattern ''scripts/make_area_list.py builds such a list from any registry's codelist and prints what it selected versus dropped — check both columns, because the failure is silent. An aggregate in the selected column inflates a country-level claim; a country in the dropped column disappears from it.
python3 scripts/make_area_list.py --codelist UNICEF/CL_COUNTRY/latest \
--pattern '[A-Z]{3}' --out areas.txtWhere the fidelity sweep tests the pipe, this tests whether an agent can use it — find the right indicator among hundreds, pick the right disaggregation, and decline when the data is absent.
scripts/sdmx_eval_build_cases_from_sweep.py builds a stratified manifest from the sweep's verified ground truth, so the answer key needs no extra registry traffic. Each case carries promptStyle, and the grade should be read per stratum — blending them hides both halves.
| style | tests |
|---|---|
prescriptive |
codes supplied — a baseline, not a capability measure |
natural |
indicator and country by name, agent resolves the codes |
ambiguous |
colloquial phrasing with one defensible answer |
disaggregated |
a sex-specific slice where picking the total is wrong |
negative |
a cell with no data — the agent must abstain |
python3 scripts/sdmx_eval_build_cases_from_sweep.py \
--ground-truth tmp/fidelity/ground_truth.csv \
--manifest tmp/sdmx_eval/cases.jsonl --count 500
python3 scripts/sdmx_eval_runner.py run-provider \
--config "$CONFIG" --manifest tmp/sdmx_eval/cases.jsonl \
--responses tmp/sdmx_eval/responses.jsonl --retry-failed
python3 scripts/sdmx_eval_runner.py grade-results \
--config "$CONFIG" --manifest tmp/sdmx_eval/cases.jsonl \
--responses tmp/sdmx_eval/responses.jsonl --grades tmp/sdmx_eval/grades.jsonl--retry-failed re-runs only errored cases, dropping the stale records first. Without it an errored case counts as done, so one transient 429 poisons it until the file is deleted by hand.
Grading scores retrieval, not reporting style. A filter matches on code or label (San Marino and SMR both pass), an omitted dimension is unknown rather than wrong, and rounding sets a notes field instead of failing. A trace_match check confirms the agent actually queried the expected series — a right number reached some other way is not a pass, and a negative case must have looked before abstaining.
--hints is registry-specific and cannot be defaulted. It maps a colloquial phrase to the single code it should resolve to. A phrase matching several indicators tests whether the agent guesses the same one you did, which manufactures failures — map only phrases with one defensible answer, or omit the file to skip the stratum.
Nothing in either rig is UNICEF-specific; the defaults just name the registry they were written against.
python3 scripts/sdmx_eval_build_cases_from_sweep.py \
--flow AGENCY/SOME_FLOW/1.0 \
--area-dim REF_AREA --indicator-dim INDICATOR \
--area-codelist AGENCY/CL_AREA/latest \
--indicator-codelist AGENCY/CL_INDICATOR/latest \
--hints my_registry_hints.jsonscripts/agent_test_rig.py (direct call harness) simulates the intended LLM flow.
Capabilities:
- builds direct SDMX manifest cases from configured indicator and reference-area codelists
- filters inspected flows by optional agency
- does not auto-fill non-essential dimensions, leaving unspecified dimensions as SDMX wildcards
- logs attempts verbosely
- saves per-case JSON outputs
Single run:
python scripts/agent_test_rig.py \
--agency <AGENCY> \
--indicator-codelist-url https://<registry>/codelist/<AGENCY>/<INDICATOR_CODELIST>/1.0 \
--country-codelist-url https://<registry>/codelist/<AGENCY>/<REF_AREA_CODELIST>/1.0 \
--manifest-onlyscripts/sdmx_eval_runner.py is a config-driven evaluation harness for any SDMX registry the server can query.
It supports three steps:
build-cases: deterministically expands configured dimensions and years into a manifest of natural-language prompts plus direct SDMX ground truth.run-provider: sends each case to a provider adapter over stdin/stdout so you can plug in OpenAI, Anthropic, or any other MCP-capable client without changing the harness.grade-results: compares structured claims from provider output against the direct SDMX result set.
Example:
python3 scripts/sdmx_eval_runner.py build-cases \
--config scripts/sdmx_eval_config.example.json \
--case-limit 25
python3 scripts/sdmx_eval_runner.py run-provider \
--config scripts/sdmx_eval_config.example.json \
--case-limit 25
python3 scripts/sdmx_eval_runner.py grade-results \
--config scripts/sdmx_eval_config.example.jsonUNICEF global dataflow smoke/eval config:
python3 scripts/sdmx_eval_runner.py build-cases \
--config scripts/sdmx_eval_config.unicef_global.example.json \
--manifest tmp/sdmx_eval/unicef_global_cases.jsonl \
--case-limit 12
export ANTHROPIC_API_KEY=...
python3 scripts/sdmx_eval_runner.py run-provider \
--config scripts/sdmx_eval_config.unicef_global.example.json \
--manifest tmp/sdmx_eval/unicef_global_cases.jsonl \
--responses tmp/sdmx_eval/unicef_global_responses.jsonl \
--case-limit 12
python3 scripts/sdmx_eval_runner.py grade-results \
--config scripts/sdmx_eval_config.unicef_global.example.json \
--manifest tmp/sdmx_eval/unicef_global_cases.jsonl \
--responses tmp/sdmx_eval/unicef_global_responses.jsonl \
--grades tmp/sdmx_eval/unicef_global_grades.jsonlThis config targets UNICEF/GLOBAL_DATAFLOW/1.0, the flow behind:
https://sdmx.data.unicef.org/ws/public/sdmxapi/rest/data/UNICEF,GLOBAL_DATAFLOW,1.0/all?format=csv&labels=name
It builds direct SDMX ground truth first, then asks an MCP-connected agent to answer the same prompts through the configured MCP endpoint. The UNICEF config instructs the agent to use compact routing tools:
get_single_observationfor one country, one indicator, and one period/latest.get_indicator_tablefor multi-country, regional, comparison, or table-shaped requests.get_time_seriesfor trend, history, or chart-shaped requests.
The grader checks the agent's structured claims for the numeric value, time period, flow reference, code filters, required MCP tool use, and concise answer shape so failures expose either retrieval errors, routing errors, or hallucinated claims.
Policy contract eval:
python3 scripts/mcp_policy_contract_eval.py \
--config scripts/mcp_policy_contract.example.jsonThis direct MCP eval does not call an LLM or spend Anthropic tokens. It compares strict and permissive deployments for the auto_apply_total contract: strict wildcard queries stay multi-series, explicit total queries resolve, permissive wildcard queries surface appliedDefaults, explicit user filters override defaults, and explicit queries match across both modes.
Anthropic setup:
export ANTHROPIC_API_KEY=...
python3 scripts/sdmx_eval_runner.py run-provider \
--config scripts/sdmx_eval_config.example.json \
--case-limit 25GitHub Actions setup:
- Add
ANTHROPIC_API_KEYas a repository or environment secret. - Run UNICEF Global MCP Eval manually from the Actions tab.
- Use
target_ref=unicefwhen evaluating the UNICEF branch. - Start with a small
case_limitsuch as4, then run the full manifest once the smoke test passes. - Check the Summarize grades step for pass/fail counts, token usage, and estimated cost.
- Download the
unicef-global-evalartifact to inspect the generated manifest, raw provider responses, and grade records.
The workflow is intentionally workflow_dispatch only. It does not run on pull_request or public fork events, and its GITHUB_TOKEN permissions are limited to contents: read. Public users cannot spend your Anthropic key just by opening a PR. Anyone with enough repository permission to manually run workflows could run this job with the configured secret, so keep write/workflow access limited and prefer an environment-protected secret if you need an approval gate for eval runs.
The config file controls:
registry_profile: whether the registry/dataflows should be tested asdenseorsparse.test_mode: whether to buildpositive,negative, ormixedcase sets.dataflows: exact flows to test.dimensions: how each dimension should be expanded.query_mode: whether cases use explicitTIME_PERIODvalues orlastNObservations.negative_case_options: how sparse/negative cases should be generated.prompt_template: how deterministic cases become natural-language prompts.provider: the adapter command that will call an actual model stack.
Supported dimension modes:
fixed: use an explicit list of code IDs.flow_dimension: use all codes from the flow DSD for that dimension.external_codelist_intersection: intersect the flow dimension codelist with an external SDMX codelist URL.time_range: generate yearly cases across a configured start/end range.
Supported query modes:
explicit_time_range: build one case per configured time value from theTIME_PERIODdimension config.last_n_observations: omit explicit time values and query withlastNObservations, which is useful for latest-single-value benchmarks.
Supported registry profiles:
dense: use when most valid intersections are expected to resolve.sparse: use when many syntactically valid intersections are expected to return no observations.
Supported test modes:
positive: benchmark value correctness on resolvable cases.negative: benchmark abstention/non-hallucination on deliberately empty cases.mixed: generate both positive and negative cases in one manifest.
Supported negative-case strategies:
swap_dimension_value: replace one configured dimension value with another valid code and keep the mutated case only when the direct SDMX query returns no observations.shift_year: for explicit time-range cases, move the requested year and keep the mutated case only when the direct SDMX query returns no observations.
The provider adapter receives a JSON payload on stdin and must print a JSON object to stdout.
Important output fields:
answer_text: the model's final natural-language answer.claims.value: the value the model claims is correct.claims.time_period: the reported year or period.claims.flowRef: the flow the model believes it used.claims.filters: the code-level filters the model believes it used.
The grader relies primarily on those structured claims, not on free-form prose alone.
Diagnostic fields, for attributing a failure rather than guessing at it:
served_by: the inference host(s) that answered, one per step. For an open-weight model the numerical precision, the sampling defaults and the tool-call parser all belong to the host rather than to the weights, so without this a cluster of failures cannot be told apart from a bad route.truncated,steps_exhausted: harness ceilings hit, each distinct from a wrong answer and from a malformed one.finish_reason,reasoning_tail(failures only): where the answer went when there is no answer. A reasoning model can finish normally having written its response into a reasoning channel and leftcontentempty — worth ruling out before concluding the model produced nothing.
scripts/sdmx_eval_provider_anthropic.py calls the Anthropic Messages API and passes your MCP server via the mcp_servers request field. It expects:
ANTHROPIC_API_KEYin the environment by defaultmcp.urlin the eval config- an MCP server reachable over HTTP(S)
The adapter asks Claude to return a single JSON object so grading can stay deterministic. If you need auth on the MCP endpoint, set mcp.authorization_token_env in the config and the adapter will forward that bearer token to Anthropic's MCP connector request.
A manifest's expected values are a snapshot of the registry at the moment the
sweep ran, and a live statistical registry revises data in weeks, not years.
Measured: a manifest built on 5 October 2026 and graded on the 20th scored
every model at 94% on the prescriptive stratum, because UNICEF had restated the
PV_CHLD_* child-poverty family in that window (source "DHS VII" to
"DHS 2018 (IPUMS)", one series from 18.5535945892334 to 23.67173993) and
withdrawn four series entirely. Models were penalised twice: once for reporting
the current value, once for correctly answering null where data no longer
existed.
Refreshing costs nothing — registry reads, no model calls — so run it immediately before grading rather than trusting the manifest's snapshot:
python3 scripts/sdmx_eval_refresh_ground_truth.py \
--manifest tmp/eval/manifest.jsonl \
--out tmp/eval/manifest.fresh.jsonlThen grade against the refreshed manifest. Responses already collected can be re-graded without re-running a single model, so this fixes staleness retroactively.
It is deliberately conservative: a value is rewritten only when exactly one observation pins it. A period returning several observations, or a fetch that errors, leaves the case exactly as it was rather than inventing ground truth — a transport failure must never read as "the data is gone".
Three things it does that are easy to miss.
Withdrawal flips the expectation, not just the value. When a series is gone
the expected value becomes null and the case's caseType becomes
negative with expectedBehavior: abstain_no_data. Nulling the value alone
left the case contradicting itself — the grader reads the top-level fields — so
a model that correctly abstained scored manual_review, a verdict nobody goes
back to review. Six withdrawn PV_CHLD_* cases sat that way and cost every
measured model a point. This is applied even when the value was already null,
so a manifest an earlier pass left half-refreshed gets repaired.
It retries, because the registry throttles without saying so. Limits are per
client address, hard, and carry no Retry-After and no rate-limit headers,
so a client cannot pace itself except by guessing. Without backoff, 494 of 500
cases came back 429 and were reported as "left alone" beneath a summary reading
value_changed: 0 — a refresh that checked almost nothing looked exactly like
one that confirmed everything. The summary now includes
left_alone_reasons, so you can see why cases were skipped.
Every answered case is stamped verified_against_registry, whether or not
its value moved. The grader needs that to tell "the model abstained and a value
demonstrably exists" from "the model abstained and nobody checked". A throttled
case is never stamped.
Summary fields: value_changed, unchanged, left_alone,
left_alone_reasons, reclassified_to_abstain, and
negative_cases_that_now_have_data (those are no longer negative cases and
need regenerating).
A model answering null where the manifest expects a value is either a miss or
a correct refusal against stale ground truth, and the grader cannot tell which
by itself. It used to defer every such case to manual_review; 55 accumulated
across seven models, and checking them by hand against the registry found 54
genuine misses and one withdrawn series that refreshing had already handled.
So the rule is narrow: an abstention is a failure when the ground truth
carries verified_against_registry from the refresh pass, and still
manual_review when it does not. Failing an abstention against truth that has
quietly gone stale is the original error, and it is worse than deferring.
A provider error is not a wrong answer, and grading it as one is
indistinguishable from incompetence in the final table. Measured: a 500-case
run showed 116 empty responses from one model; a single --retry-failed pass
recovered 70 of them and moved that model from 68.4% to 84.4%. Across nine
models, 474 outstanding provider errors were pending at one point.
Re-run the same sweep command: each model with errored rows gets
--retry-failed, complete models are skipped, and everything is re-graded.
Then confirm the grades were actually rewritten — a sweep that runs the retry
but dies before grading leaves stale verdicts beside improved responses, which
reads as "the retry did nothing".
scripts/sdmx_eval_provider_gateway.py runs the same eval against any model
reachable through Vercel AI Gateway, so
one manifest can be scored across providers. It expects:
AI_GATEWAY_API_KEYin the environment (create the key in the Vercel dashboard under AI Gateway)mcp.urlin the eval config- a model id in
provider/modelform, e.g.openai/gpt-6-astra,google/gemini-3-pro,anthropic/claude-haiku-4.5
Start from scripts/sdmx_eval_config.unicef_global.gateway.example.json:
export AI_GATEWAY_API_KEY=...
python3 scripts/sdmx_eval_runner.py run \
--config scripts/sdmx_eval_config.unicef_global.gateway.example.json \
--manifest tmp/eval/manifest.jsonl \
--responses tmp/eval/responses.gpt.jsonlSDMX_EVAL_MODEL overrides the configured model, so one config drives a whole
comparison:
for model in openai/gpt-6-astra google/gemini-3-pro anthropic/claude-haiku-4.5; do
SDMX_EVAL_MODEL="$model" python3 scripts/sdmx_eval_runner.py run \
--config scripts/sdmx_eval_config.unicef_global.gateway.example.json \
--manifest tmp/eval/manifest.jsonl \
--responses "tmp/eval/responses.${model//\//-}.jsonl"
doneOne caveat when comparing against Anthropic-adapter results. The Anthropic adapter uses Anthropic's hosted MCP connector: tool calls execute on Anthropic's side. A gateway is a plain Chat Completions endpoint with no such facility, so this adapter runs the MCP client loop locally — it lists the server's tools, converts their schemas to OpenAI function definitions, executes each requested call over Streamable HTTP, and feeds the result back. The system prompt, contract wording, manifest, ground truth and grader are held identical, but the tool transport is not. A score gap between the two adapters is not purely a model difference.
Cost is read from the gateway's own per-request usage.cost rather than from a
local price table, since the gateway bills at list price with no markup.
provider.deny_tools withholds named tools from the model. It exists for
ablation: strip the curated discovery and resolution tools, leave the ones
corresponding to native SDMX REST operations (get_flow_structure,
list_dimensions, list_codes, get_single_observation, query_data,
get_time_series), and measure what the guided layer is worth. It refuses to
run if the denylist would leave no tools at all.
Measured on the resolution strata: the best model tested fell from 99.4% to 54.8% and the next from 97.8% to 2.7%, with tool calls per question rising from 5.3 to between 35 and 98 and a third to a half of cases exhausting the step ceiling. Treat that as a lower bound on the discovery layer's value rather than an estimate — the ablated arm still receives structured tool schemas, the validation layer explaining each malformed request, scope enforcement and caching, and never has to build a URL or parse SDMX-JSON. A model working against the raw REST interface has none of that.
If you set deny_tools, strip tool names out of provider.system_prompt too.
Routing advice naming tools the model cannot see measures prompt/tool mismatch
rather than the missing layer.
scripts/sdmx_eval_model_sweep.py runs one manifest across several models and
tabulates accuracy against measured cost, per stratum. Each model is run and
graded through the normal runner, so a sweep is exactly equivalent to issuing
the commands by hand — it only removes the bookkeeping, and every number still
traces back to the transcript that produced it.
python3 scripts/sdmx_eval_model_sweep.py \
--config scripts/sdmx_eval_config.unicef_global.gateway.example.json \
--manifest tmp/eval/manifest.fresh.jsonl \
--out-dir tmp/sweep \
--summary-json tmp/sweep/summary.json \
--models zai/glm-5.3-flash deepseek/deepseek-v4.1-flash anthropic/claude-haiku-5.5Models are discovered from the run directory by the downstream analysis rather than listed in it, and responses and grades are kept per model.
It is resumable, and that is load-bearing. Re-running the same command
retries each model's errored rows with --retry-failed, skips models that are
already complete, and re-grades. Three behaviours exist because each one cost a
published-looking number:
- The runner treats any case already in the file as done, errored rows
included. Without
--retry-faileda re-run prints "running" and writes nothing. After a network outage left 408 of 500 cases failed, a sweep reported it was re-running them, reproduced the same file, and the resulting 16.8% read as the model's score. The sweep now raises if a retry adds no cases. - A torn final line is tolerated; corruption is not. The runner appends from
several workers, so reading immediately after it exits can catch the last line
mid-write — which once ended a sweep with a bare
JSONDecodeErrorafter seven models had been measured, on a file that was valid moments later. A malformed last line is skipped as a partial append; a malformed line with others after it is real corruption and stops the sweep, naming the file and line. --retry-failedrewrites the responses file atomically. Records reach 1.7MB, so rebuilding in place leaves a long window where the file is half written; one 500-case run came back with a record torn across two lines. The rewrite now writes a sibling and renames.
Screen before you commit. Capability at tool use varies far more than price does, and the gap does not appear on easy questions. Screening 11 models on a 74-case manifest cost $4.52 and separated models that drive the server from ones that cannot — one of which never successfully called a tool at all, and answered population questions from memory instead. Spend a full run only on what survives.
scripts/sdmx_eval_normalise_claims.py applies the terse-answer claim recovery
to response files already collected, so a harness improvement can be applied
retroactively without re-running a model.
Four scripts turn graded output into the numbers a report can quote. They take a run directory, discover the models in it by scanning rather than from a list, and write their JSON into that directory.
# Gather runs; later sources supersede earlier ones, so list a retry after the
# original it replaces.
python3 scripts/sdmx_eval_consolidate.py tmp/final tmp/sweep tmp/sweep-retried
python3 scripts/sdmx_eval_stats.py tmp/final # -> tmp/final/stats.json
python3 scripts/sdmx_eval_pooled.py tmp/final # -> tmp/final/pooled.json
python3 scripts/sdmx_eval_variance.py tmp/repeats # -> tmp/repeats/variance.jsonstats.py reports accuracy with a Wilson interval rather than the normal
approximation, because these proportions sit near 1 where the normal interval
runs past 100%. It also reports the token profile, since input dominates by
roughly 35:1 on this workload and a per-token price list therefore misleads
about cost per task.
pooled.py reports the per-stratum spread across models, which is the finding
worth having: models agree where a query is fully specified and diverge where
codes must be resolved. It excludes models averaging under one tool call per
case, decided on tool use rather than on score — a model that never gets the
server to answer records zero on every stratum, which makes every range the
full 100 points and erases the gradient. Pooled pass rates include every model,
and the exclusion is reported in the output.
variance.py reports the accuracy spread across repeats and the share of
individual cases that change verdict, because a stable headline can sit on top
of unstable answers. It also tabulates pass rate by serving host where a model
was served by more than one.
Two refusals worth knowing about. All of them stop rather than average when
a model's grades and responses have different lengths — that pairing reports
accuracy from one run and cost from another, and it happened once with 500
graded cases against 100 re-run responses. consolidate.py additionally
refuses a file whose rows contain duplicate case_ids, since those would be
graded twice.
A single run is an estimate. Measured over three identical runs of the same 148 cases: competent models vary by 0.7 to 2.0 points, and that is a reasonable error bar to carry against any single figure. One model varied by 10.1 points, with half its cases returning a different verdict between identical runs — a far more disqualifying property than its headline score, and invisible to any single run at any sample size.
Report both the accuracy spread and the share of individual cases that change verdict. A stable headline can sit on top of unstable answers: one model held a 1.4-point spread while 4.7% of its cases churned, three times the rate of the most stable model measured.
Run-to-run variance tracked model competence rather than open-weight versus commercial licensing — a first-party commercial model and the best open-weight model returned identical 0.7-point spreads. And when the serving host was recorded for every request of the least stable model, all 444 went to the same host, so its instability is within-host rather than an artefact of the gateway switching between them.
If Claude supports remote MCP connectors in your plan/UI:
- add connector URL:
https://<your-app>.fly.dev/mcp - test with a first prompt like: "List agencies"
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"sdmx": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://<your-app>.fly.dev/mcp"]
}
}
}Restart Claude Desktop after editing.
For codelist-backed dimensions, build_key and query_data(filters=...) must use code IDs (as returned by list_codes), not display labels.
If a caller passes a manual key with too few segments, query_data pads missing trailing dimensions as wildcard segments automatically.
Recommended discovery sequence:
find_indicator_candidates(query)to find indicator IDs across scoped flows.- Use each candidate's
recommendedFlowRef(or inspectdataflows) to choose a flow. list_codes(flowRef, "GEO", query=...)and other dimension tools to constrain the slice.- If a selected code may be aggregate, call
resolve_hierarchy(...)orresolve_dimension_fallback(...). validate_query_scope(flowRef, filters=...)orbuild_key(...)thenvalidate_query_scope(...).- Only if the preflight resolves, call
query_data(flowRef, filters=...)orbuild_key(...)thenquery_data(...).
406 Not Acceptable
- Cause: client
Acceptheader mismatch for endpoint/transport. - Check: use
/mcpand includeAccept: application/jsonfor JSON-RPC POST tests.
Missing session ID
- Some MCP transport/client paths require a full MCP handshake session flow.
- Validate with
tools/listvia known-good client first.
404 Not Found
- Usually wrong path. Use
/mcp(not/).
- Data query 404 with SDMX message
No data for data query against the dataflow
- Query syntax is valid but the selected dimensional slice has no observations.
- Use
find_indicator_candidatesand inspect the returned candidate flows, or configure strongerflow_topic_hintsindiscovery_policy.json.