Skip to main content
Glama
nescoffee-create

SDMX MCP Gateway

README.md
# SDMX MCP Gateway

A Model Context Protocol (MCP) server that provides progressive discovery tools for SDMX statistical artefacts and data. This implementation enables AI agents to explore and access SDMX-compliant statistical data repositories through interactive tools, resources, and prompts.

**Version 0.2.0** - Now with structured outputs, Streamable HTTP transport, and elicitation support.

## šŸš€ Key Features

- **Progressive Discovery**: Reduces metadata transfer from 100KB+ to ~2.5KB
- **Structured Outputs**: All tools return validated Pydantic models
- **Multiple Transports**: STDIO (development) and Streamable HTTP (production)
- **Interactive Elicitation**: User confirmation dialogs for endpoint switching
- **Multi-Provider Support**: SPC, FBOS, SBS, ECB, UNICEF, IMF, OECD, ESTAT, ILO, ABS, BIS

## Quick Start

A public instance of this server is hosted on Railway. Point any MCP client at the URL below and you can skip cloning, installing dependencies, and managing a Python environment.

```
https://sdmx-mcp-gateway-production.up.railway.app/mcp
```

Transport is Streamable HTTP. The endpoint is shared and stateless from the client's perspective; each MCP session gets its own server-side state (endpoint selection, client pool, mismatch-hint cache).

Quick check that it responds:

```bash
curl -X POST https://sdmx-mcp-gateway-production.up.railway.app/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
```

See [MCP Client Configuration](#mcp-client-configuration) for ready-to-paste configs for Claude Code, Claude Desktop, Codex, Cursor, Zed, and OpenCode.

### Health Status

A standalone monitor checks the hosted gateway and every provider endpoint
(through the gateway and directly) every two hours. See `monitor/README.md`
for running or deploying it. Once deployed, its status page shows current
health, per-endpoint history, and whether a failure sits in the gateway or
the upstream provider.

### Self-Hosting

If you prefer to run the server yourself (offline use, private deployments, development on the tools themselves), see [Installation](#installation) and [Running the Server](#running-the-server).

```bash
cd sdmx-mcp-gateway
uv sync
uv run python main_server.py                                     # STDIO, for local clients
uv run python main_server.py --transport http --port 8000        # HTTP, for remote clients
uv run mcp dev ./main_server.py                                  # MCP Inspector (browser UI)
```

## The Problem We Solve

Traditional SDMX queries with `references=all` return 100KB+ of XML metadata, overwhelming LLM context windows. Our progressive discovery approach provides a layered exploration:

| Step      | Operation                        | Data Size  |
| --------- | -------------------------------- | ---------- |
| 1         | Find dataflows by keyword        | ~300 bytes |
| 2         | Get dimension structure          | ~1KB       |
| 3         | Explore specific dimension codes | ~500 bytes |
| 4         | Check data availability          | ~700 bytes |
| 5         | Build final query URL            | ~200 bytes |
| **Total** |                                  | **~2.5KB** |

## Architecture

```
sdmx-mcp-gateway/
ā”œā”€ā”€ main_server.py              # FastMCP server with CLI
ā”œā”€ā”€ app_context.py              # Lifespan management & shared resources
ā”œā”€ā”€ config.py                   # Endpoint configuration
ā”œā”€ā”€ sdmx_progressive_client.py  # SDMX 2.1 REST client
ā”œā”€ā”€ utils.py                    # Validation & utilities
ā”œā”€ā”€ models/
│   ā”œā”€ā”€ __init__.py
│   └── schemas.py              # Pydantic output schemas
ā”œā”€ā”€ tools/
│   ā”œā”€ā”€ sdmx_tools.py           # Discovery tools implementation
│   └── endpoint_tools.py       # Endpoint management
ā”œā”€ā”€ resources/
│   └── sdmx_resources.py       # MCP resources
ā”œā”€ā”€ prompts/
│   └── sdmx_prompts.py         # Guided prompts
└── tests/                      # Test suite
```

## Available Tools

### Discovery Tools

| Tool                     | Description                               | Output Schema               |
| ------------------------ | ----------------------------------------- | --------------------------- |
| `list_dataflows`         | Find dataflows by keyword                 | `DataflowListResult`        |
| `get_dataflow_structure` | Get dimensions and structure              | `DataflowStructureResult`   |
| `get_dimension_codes`    | Explore codes for a dimension             | `DimensionCodesResult`      |
| `get_data_availability`  | Check what data exists                    | `DataAvailabilityResult`    |
| `get_structure_diagram`  | Generate Mermaid diagram of relationships | `StructureDiagramResult`    |
| `compare_structures`     | Compare two structures for differences    | `StructureComparisonResult` |
| `validate_query`         | Validate query parameters                 | `ValidationResult`          |
| `build_key`              | Construct SDMX key                        | `KeyBuildResult`            |
| `build_data_url`         | Generate data retrieval URL               | `DataUrlResult`             |
| `get_codelist`           | Browse specific codelist                  | `dict`                      |

SDMX 2.1 has no server-side pagination, so `list_dataflows` fetches a provider's entire dataflow listing under the hood even when `limit` is small; for ESTAT that listing alone is 37 MB. The parsed result is cached process-wide (shared across every session, not per client) for `DATAFLOW_CACHE_TTL_S` seconds (default `900`, 15 minutes), keyed on the base URL, agency, and the other parameters that change the answer. The result's `next_step` field always states whether that call was served from cache and, if so, how old the entry is, so a caller never has to guess. Pass `fresh=True` to bypass the cache and force a live re-fetch; the fresh result still refreshes the cache for everyone else. Use `fresh=True` for liveness checks, where a cached answer would say nothing about whether the provider is reachable right now.

### Reference Metadata

| Tool                      | Description                                          | Output Schema             |
| -------------------------- | ----------------------------------------------------- | -------------------------- |
| `get_reference_metadata`  | Summarise source, methodology, licence and caveats for a dataflow | `ReferenceMetadataResult` |
| `get_metadata_attribute`  | Get every value of one metadata attribute, with the slice each applies to | `MetadataAttributeValuesResult` |

Reference metadata is the descriptive material about a dataflow rather than its structure: who compiled it, from what source, under what licence, with what caveats. Coverage varies by provider, so the result's `channels` field reports which channel was available: `.Stat Suite` deployments (SPC, FBOS, SBS, OECD) publish it through a v2 MSD query, some other providers carry equivalent detail only in ordinary DSD attributes on the data message, and some publish neither. A channel status of `inconclusive` means the query did not produce a usable answer; that is different from a confirmed absence, which is what `empty` reports.

Pass `key` to narrow the query to one series. This matters for large dataflows: SPC's `DF_SDG` metadata query is 5.37 MB unfiltered against 5.6 KB with a partial key, and the tool refuses an unfiltered query over 2 MB (`too_broad`) by aborting the read partway through rather than downloading it in full; the same cap applies to both the MSD query and the DSD-attribute fallback used by providers without a `/v2/` endpoint.

#### `get_reference_metadata`: the summary

`get_reference_metadata` returns one entry per attribute the provider declares, in `metadata_attributes`, plus a `coverage` count and a per-channel `channels` status. Each attribute carries:

- `status`: `populated` when the provider published at least one value, `declared_empty` when every occurrence in the response read was blank -- for the whole dataflow when no key was supplied, or for the slice queried when one was, since a keyed request only reads that slice. A declared-but-empty attribute is a real, observed answer, not a missing one, and it is listed rather than omitted, so a blank licence field reads differently from a provider that has no licence concept at all.
- `value` and `drill_down`: `value` carries the headline text when exactly one distinct value exists and either it describes the whole dataflow (`dataflow`/`dataset` scope, provider-marked unqualified), or it was identical on every data row this query actually returned (`all_observed_rows` scope). The second case is common: SPC's `DF_SDG` publishes the same value on every per-country row and has no dataflow-wide row at all, so before this rule every one of its populated attributes came back `value: null`. `drill_down` is `false` only for `dataflow`/`dataset` scope; for `all_observed_rows` it stays `true`, since that scope says nothing about rows the query did not return (a different key, or rows beyond a truncated response's row cap) and the per-row detail behind it still matters. When the attribute's values differ across the rows read, `value` is `null` and `drill_down` is `true`, meaning no single value can stand in as the answer.
- `distinct_values` and `scope`: how many distinct values were found, and what the headline (when present) attaches to: the whole dataflow (`dataflow`/`dataset`), every row this query returned (`all_observed_rows`, weaker than `dataflow`: no provider ever marked it unqualified), or one slice (`partial_key`).

`coverage` (`declared` / `populated` / `empty`) is reported only when the MSD channel itself answered `found` on an untruncated read: that is the only channel that can see a declared-but-empty attribute (`parse_msd_csv`'s `declared_empty` status). Neither an MSD `empty` answer nor the DSD-attribute fallback can establish it, even when both agree: the DSD fallback only ever sees what a message actually populates, so it can never confirm that a provider declares nothing further, and a truncated MSD read cannot rule out a value past the row cap. `coverage` is `None` in every one of those cases -- when the only channel that answered was the DSD-attribute fallback, which shows populated attributes only; when the MSD channel answered `empty` (with or without the DSD fallback also resolving); or when the MSD channel found something but the read was cut off before finishing.

#### `get_metadata_attribute`: the drill-down

Call `get_metadata_attribute(dataflow_id, attribute_id, key=None, agency_id=None)` after `get_reference_metadata` reports `drill_down: true` for an attribute, to read every distinct value with the dimension key (`key_context`) it applies to. `attribute_id` is the short `id` from the summary, not the full dotted `path`. The result (`MetadataAttributeValuesResult`) has no separate error field; every case below is a normally-shaped result distinguished by `total` and by the text of `notes`. Four answers matter and are kept distinct:

- **Populated**: one entry per distinct (value, `key_context`) pair; the same value may appear multiple times with different `key_context` values. `total` counts these pairs, while the summary's `distinct_values` counts distinct values only (the two need not match), and `truncated` is `true` when more than 200 pairs exist, with `values` limited to the first 200.
- **Declared but empty**: `total: 0`, `values: []`, and a note stating the attribute is declared and left blank -- for the whole dataflow when no key was supplied, for the slice queried when one was. The first note does not start with `"Error:"`.
- **Unknown attribute id**: `total: 0`, `values: []`, and a first note starting with `"Error: "` reading `"Error: Unknown attribute '<id>' for <dataflow>: declared attributes are <ids>"`, naming the dataflow's declared attribute ids so a typo reads as "here is what exists" rather than as an empty result. This wording is only used when the MSD channel itself answered `found`: that is the one channel outcome that can vouch for a provider's full declared set, so the listed ids are genuinely everything declared. A caller distinguishes this from the declared-but-empty case above by checking whether the first note starts with `"Error:"`, not by looking for a field that does not exist on this result.
- **No channel confirmed a declared set**: `total: 0`, `values: []`, and notes explaining which channel could not answer and why that is not evidence the attribute is missing. This covers an MSD channel that answered `too_broad`, `inconclusive` or `unsupported`, and also the MSD channel's own `empty` answer -- even when the DSD-attribute fallback separately found something or itself resolved to `empty`, since that fallback only ever sees what a message actually populates and can never see an attribute the DSD declares but a given response leaves blank, so it cannot vouch for the full declared set on its own. Like the declared-but-empty case, the notes here do not start with `"Error:"`.

Each value's `key_context` is `null` in two different situations that a caller must not conflate: from the MSD channel, `null` means the value is genuinely dataflow-wide; from the DSD-attribute fallback channel (used by providers without a `/v2/` endpoint), `null` means that channel has no per-value key to report at all, even when the value actually attaches to one series or observation rather than to the whole dataflow. A note on the result says which channel supplied the attribute when this applies.

### Endpoint Management

| Tool                          | Description                            | Output Schema          |
| ----------------------------- | -------------------------------------- | ---------------------- |
| `get_current_endpoint`        | Show the session's default provider    | `EndpointInfo`         |
| `list_available_endpoints`    | List all configured providers          | `EndpointListResult`   |

The session default is set once at startup from the `SDMX_ENDPOINT` env var and is not mutable at runtime. To target a specific provider for an individual call, pass `endpoint=<KEY>` to any endpoint-scoped tool.

### Resources

- `sdmx://agencies` - List of known SDMX data providers
- `sdmx://agency/{id}/info` - Specific agency details
- `sdmx://formats/guide` - Data format comparison
- `sdmx://syntax/guide` - Query syntax reference

### Prompts

- `discovery_guide` - Step-by-step data discovery workflow
- `troubleshooting_guide` - Common issue resolution
- `best_practices` - Use-case specific guidance
- `query_builder` - Interactive query construction

## Supported Data Sources

| Key      | Provider                         | Description                           | Constraints        |
| -------- | -------------------------------- | ------------------------------------- | ------------------ |
| `SPC`    | Pacific Data Hub                 | Pacific regional statistics (default) | Actual (single + bulk) |
| `FBOS`   | Fiji Bureau of Statistics        | Fiji official national statistics     | Actual (single + bulk) |
| `SBS`    | Samoa Bureau of Statistics       | Samoa official national statistics    | Actual (single + bulk) |
| `ECB`    | European Central Bank            | European financial statistics         | Allowed (single + bulk) |
| `UNICEF` | UNICEF                           | Children and youth statistics         | Actual (single + bulk) |
| `IMF`    | International Monetary Fund      | Global financial statistics           | Actual (single) |
| `OECD`   | OECD                             | Economic and social statistics        | Actual (single) |
| `BIS`    | Bank for International Settlements | International financial statistics  | Actual (single) |
| `ABS`    | Australian Bureau of Statistics  | Australian official statistics        | Actual (single) |
| `ILO`    | International Labour Organization | Labour and employment statistics     | Actual (single) |
| `ESTAT`  | Eurostat                         | European Union official statistics    | None |

Target a provider per call:

```python
# Pass endpoint= on any endpoint-scoped tool
list_dataflows(endpoint="ECB", limit=10)
get_dataflow_structure(dataflow_id="DF_CPI", endpoint="FBOS")

# Or rely on the session default (set at startup from SDMX_ENDPOINT env var)
list_dataflows(limit=10)
```

See `docs/ENDPOINT_CONFIGURATION.md` for provider-specific behaviours and constraint strategies.

## Installation

### Prerequisites

- Python 3.12 or higher
- [uv](https://docs.astral.sh/uv/) (recommended) or pip

### Using UV (Recommended)

```bash
cd sdmx-mcp-gateway
uv sync
```

### Using pip

```bash
cd sdmx-mcp-gateway
pip install -r requirements.txt
```

### Dependencies

- `mcp[cli]>=1.26.0` - Model Context Protocol SDK
- `pydantic>=2.0.0` - Structured output validation
- `httpx>=0.27.0` - Async HTTP client
- `certifi>=2024.0.0` - SSL certificates

## Running the Server

### CLI Options

```bash
uv run python main_server.py [OPTIONS]

Options:
  --transport, -t    Transport type: stdio, http, streamable-http (default: stdio)
  --host             Host for HTTP transport (default: HOST env or 0.0.0.0)
  --port, -p         Port for HTTP transport (default: PORT env or 8000)
  --stateless        Run in stateless mode (HTTP only)
  --json-response    Use JSON responses instead of SSE (HTTP only)
  --debug            Enable debug logging
```

### Development Mode (STDIO)

```bash
# Direct execution
uv run python main_server.py

# With MCP Inspector (opens browser UI)
uv run mcp dev ./main_server.py
```

### Production Mode (Streamable HTTP)

```bash
uv run python main_server.py --transport streamable-http --host 0.0.0.0 --port "${PORT:-8000}"
```

For container platforms such as Vercel, Railway, or Fly, prefer binding to `0.0.0.0`
and reading the port from the platform-provided `PORT` environment variable. The
server now fails fast if the installed MCP SDK ignores the requested HTTP bind
settings, rather than silently falling back to localhost.

## MCP Client Configuration

The recommended path is to point your client at the hosted Railway URL. Each section below shows:

- **Hosted (HTTP)**: uses `https://sdmx-mcp-gateway-production.up.railway.app/mcp`. Nothing to install beyond the client itself.
- **Self-hosted (STDIO)**: runs the server from a clone of this repo. Requires `uv` and `git` (see [Installation](#installation)).

Clients that only speak STDIO can still reach the hosted instance via the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge, which proxies HTTP MCP servers through stdio via `npx`.

### Claude Code

Hosted, one-liner:

```bash
claude mcp add --transport http sdmx-gateway https://sdmx-mcp-gateway-production.up.railway.app/mcp
```

Or in `.claude/settings.json` / `~/.claude/settings.json`:

```json
{
    "mcpServers": {
        "sdmx-gateway": {
            "type": "http",
            "url": "https://sdmx-mcp-gateway-production.up.railway.app/mcp"
        }
    }
}
```

Self-hosted (STDIO):

```json
{
    "mcpServers": {
        "sdmx-gateway": {
            "command": "uv",
            "args": [
                "run",
                "--directory",
                "/path/to/sdmx-mcp-gateway",
                "python",
                "main_server.py"
            ]
        }
    }
}
```

> If `uv` is not on your PATH, use the full path (e.g. `"/home/user/.local/bin/uv"`).

### OpenAI Codex CLI

Hosted, via `~/.codex/config.toml` (uses `mcp-remote` to bridge HTTP into stdio):

```toml
[mcp_servers.sdmx]
command = "npx"
args = ["-y", "mcp-remote", "https://sdmx-mcp-gateway-production.up.railway.app/mcp"]
enabled = true
tool_timeout_sec = 120
```

Or via the CLI:

```bash
codex mcp add sdmx -- npx -y mcp-remote https://sdmx-mcp-gateway-production.up.railway.app/mcp
```

Self-hosted:

```toml
[mcp_servers.sdmx]
command = "uv"
args = ["run", "--directory", "/path/to/sdmx-mcp-gateway", "python", "main_server.py"]
enabled = true
tool_timeout_sec = 120
```

> The `command` field must be the executable only. If `uv` or `npx` is not on your PATH, use the full path. Arguments go in `args` as a separate array.

### Claude Desktop

Claude Desktop does not yet speak HTTP MCP natively, so use `mcp-remote` to reach the hosted server.

Config file locations:

- **Linux**: `~/.config/Claude/claude_desktop_config.json`
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

Hosted:

```json
{
    "mcpServers": {
        "sdmx-gateway": {
            "command": "npx",
            "args": [
                "-y",
                "mcp-remote",
                "https://sdmx-mcp-gateway-production.up.railway.app/mcp"
            ]
        }
    }
}
```

Self-hosted (STDIO):

```json
{
    "mcpServers": {
        "sdmx-gateway": {
            "command": "uv",
            "args": [
                "run",
                "--directory",
                "/path/to/sdmx-mcp-gateway",
                "python",
                "main_server.py"
            ]
        }
    }
}
```

> On Windows, escape the path: `"C:\\path\\to\\sdmx-mcp-gateway"`.

### Cursor

1. Open **Cursor Settings > MCP**.
2. Add a new global MCP server.
3. Set the URL to `https://sdmx-mcp-gateway-production.up.railway.app/mcp` (Cursor supports Streamable HTTP servers directly).

For a self-hosted instance, use the STDIO command shown for Claude Code.

### Zed

Zed uses "Context Servers" for MCP integration. Settings file:

- Linux: `~/.config/zed/settings.json`
- macOS: `~/Library/Application Support/Zed/settings.json`
- Project-specific: `.zed/settings.json` in your project root

Add the `context_servers` key at the **top level** of your settings.json, alongside other settings like `theme` and `ui_font_size`.

Hosted (via `mcp-remote`):

```json
{
    "context_servers": {
        "sdmx-gateway": {
            "command": {
                "path": "npx",
                "args": [
                    "-y",
                    "mcp-remote",
                    "https://sdmx-mcp-gateway-production.up.railway.app/mcp"
                ]
            }
        }
    }
}
```

Self-hosted:

```json
{
    "context_servers": {
        "sdmx-gateway": {
            "command": {
                "path": "uv",
                "args": [
                    "run",
                    "--directory",
                    "/path/to/sdmx-mcp-gateway",
                    "python",
                    "main_server.py"
                ]
            }
        }
    }
}
```

### OpenCode

`~/.config/opencode/config.json`:

```json
{
    "mcpServers": {
        "sdmx-gateway": {
            "command": "npx",
            "args": [
                "-y",
                "mcp-remote",
                "https://sdmx-mcp-gateway-production.up.railway.app/mcp"
            ]
        }
    }
}
```

Or, for a self-hosted instance, swap to the `uv run ...` command shown in the Claude Code section.

### Generic MCP Client (Streamable HTTP)

Any client with Streamable HTTP support connects directly to the hosted URL:

```
https://sdmx-mcp-gateway-production.up.railway.app/mcp
```

To run your own HTTP instance locally:

```bash
uv run python main_server.py --transport http --port 8000
```

Point the client at `http://localhost:8000/mcp`. Add `--stateless --json-response` if your client cannot consume Server-Sent Events.

## Usage Examples

### Progressive Discovery Workflow

```python
# Step 1: Find relevant dataflows
list_dataflows(keywords=["digital", "development"])
# → Returns: DataflowListResult with matching dataflows

# Step 2: Get structure
get_dataflow_structure("DF_DIGITAL_DEVELOPMENT")
# → Returns: DataflowStructureResult with dimensions

# Step 3: Find country code
get_dimension_codes("DF_DIGITAL_DEVELOPMENT", "GEO_PICT", search_term="tonga")
# → Returns: DimensionCodesResult with TO = Tonga

# Step 4: Check availability
get_data_availability("DF_DIGITAL_DEVELOPMENT", dimension_values={"GEO_PICT": "TO"})
# → Returns: DataAvailabilityResult with time ranges

# Step 5: Build query
build_data_url("DF_DIGITAL_DEVELOPMENT", key="A..TO.", format_type="csv")
# → Returns: DataUrlResult with ready-to-use URL
```

### Structure Relationship Visualization

Understand how SDMX structures relate to each other with Mermaid diagrams:

```python
# See what a DSD references (codelists, concept schemes)
get_structure_diagram("datastructure", "DSD_DF_POP", direction="children")
# → Returns: StructureDiagramResult with mermaid_diagram field

# See what uses a codelist (impact analysis)
get_structure_diagram("codelist", "CL_FREQ", direction="parents")
# → Shows which DSDs and concept schemes use this codelist

# Get full relationship graph
get_structure_diagram("dataflow", "DF_POP", direction="both")
# → Shows both parent and child relationships

# Show version numbers on all nodes (important for impact analysis!)
get_structure_diagram("datastructure", "DSD_SDG", direction="children", show_versions=True)
# → Displays version numbers like "CL_FREQ v1.0", "CL_GEO v2.0"
# This is critical because different versions are independent -
# a dataflow using CL_FREQ v1.0 won't be affected by changes to v2.0
```

The `mermaid_diagram` field contains ready-to-render Mermaid code.

**Without versions** (default):

```mermaid
graph TD
    subgraph dataflow["Dataflows ⭐"]
        dataflow_DF_POP["šŸ“Š <b>DF_POP</b><br/>Population Statistics"]
    end
    subgraph datastructure["Data Structures"]
        datastructure_DSD_POP["šŸ—ļø DSD_POP<br/>Population DSD"]
    end
    subgraph codelist["Codelists"]
        codelist_CL_FREQ["šŸ“‹ CL_FREQ<br/>Frequency"]
        codelist_CL_GEO["šŸ“‹ CL_GEO<br/>Geography"]
    end
    dataflow_DF_POP -->|"defines structure"| datastructure_DSD_POP
    datastructure_DSD_POP -->|"uses codelist"| codelist_CL_FREQ
    datastructure_DSD_POP -->|"uses codelist"| codelist_CL_GEO
```

**With `show_versions=True`** (shows exact version dependencies):

```mermaid
graph TD
    subgraph datastructure["Data Structures ⭐"]
        datastructure_DSD_SDG["šŸ—ļø <b>DSD_SDG</b> v3.0<br/>DSD for SDG"]
    end
    subgraph codelist["Codelists"]
        codelist_CL_FREQ["šŸ“‹ CL_FREQ v1.0<br/>Frequency"]
        codelist_CL_GEO["šŸ“‹ CL_GEO v2.0<br/>Geography"]
    end
    datastructure_DSD_SDG -->|"uses codelist"| codelist_CL_FREQ
    datastructure_DSD_SDG -->|"uses codelist"| codelist_CL_GEO
```

### Comparing Structures

Identify differences between two structures (useful for version upgrades and cross-structure analysis).

**Comparing Codelists** (compares actual codes):

```python
# Compare two versions of a codelist - what codes changed?
compare_structures(
    structure_type="codelist",
    structure_id_a="CL_GEO",
    version_a="1.0",
    version_b="2.0"
)
# → Shows added/removed/renamed codes between versions

# Compare two different codelists - find intersection and differences
compare_structures(
    structure_type="codelist",
    structure_id_a="CL_FREQ",
    structure_id_b="CL_TIME_FREQ"
)
# → Shows which codes are unique to each, and which are shared
```

**Comparing DSDs** (compares codelist/conceptscheme references):

```python
# Compare two versions of a DSD - what codelist references changed?
compare_structures(
    structure_type="datastructure",
    structure_id_a="DSD_SDG",
    version_a="2.0",
    version_b="3.0"
)
# → Shows added/removed/version-changed codelist references

# Compare two different DSDs
compare_structures(
    structure_type="datastructure",
    structure_id_a="DSD_SDG",
    structure_id_b="DSD_EDUCATION"
)
# → Shows which codelists are unique to each, and which are shared
```

The comparison identifies:

- **āž• Added**: Items that exist in B but not A
- **āž– Removed**: Items that exist in A but not B
- **šŸ”„ Modified**: Same ID but changed (version change for DSD refs, name change for codes)
- **āœ“ Unchanged**: Identical items in both

Example codelist comparison output:

```
Comparing codelist CL_GEO: v1.0 → v2.0
Total codes: A has 25, B has 28

Summary: 5 change(s) detected
   - āž• Added codes: 3
   - āž– Removed codes: 0
   - šŸ”„ Name changed: 2
   - āœ“ Unchanged: 23

āž• Added codes:
   - `PW`: Palau
   - `MH`: Marshall Islands
   - `FM`: Federated States of Micronesia
```

Example DSD comparison with diff diagram:

```mermaid
graph LR
    subgraph comparison["Structure Comparison"]
        A["šŸ—ļø DSD_SDG<br/>v3.0"]
        B["šŸ—ļø DSD_EDUCATION<br/>v1.0"]
    end
    subgraph added_group["āž• Added"]
        add_CL_EDUCATION["šŸ“‹ CL_EDUCATION_INDICATORS<br/>v1.0"]
    end
    subgraph removed_group["āž– Removed"]
        rem_CL_SDG["šŸ“‹ CL_SDG_INDICATORS<br/>v3.0"]
    end
    subgraph changed_group["šŸ”„ Version Changed"]
        chg_CL_GEO["šŸ“‹ CL_GEO<br/>v1.0 → v2.0"]
    end
    A -.->|removed| rem_CL_SDG
    B -->|added| add_CL_EDUCATION
    A -.->|was| chg_CL_GEO
    B -->|now| chg_CL_GEO
    style add_CL_EDUCATION fill:#c8e6c9,stroke:#388e3c
    style rem_CL_SDG fill:#ffcdd2,stroke:#d32f2f
    style chg_CL_GEO fill:#fff9c4,stroke:#fbc02d
```

### Targeting a Provider Per Call

Every endpoint-scoped tool accepts an optional `endpoint=<KEY>` argument:

```python
list_dataflows(endpoint="ECB", limit=10)
get_dataflow_structure(dataflow_id="EXR", endpoint="ECB")
build_data_url(dataflow_id="DF_CPI", filters={"GEO_AREA": "FJI"}, endpoint="FBOS")
```

Calls without `endpoint=` use the session's default (set at server startup from the `SDMX_ENDPOINT` env var). Parallel calls to different providers are safe — each resolves independently.

## Structured Outputs

All tools return Pydantic models with validated, typed data:

```python
# Example: DataflowListResult
{
  "discovery_level": "overview",
  "agency_id": "SPC",
  "total_found": 45,
  "showing": 10,
  "offset": 0,
  "limit": 10,
  "dataflows": [
    {"id": "DF_GDP", "name": "GDP Statistics", "description": "..."},
    ...
  ],
  "pagination": {
    "has_more": true,
    "next_offset": 10,
    "total_pages": 5,
    "current_page": 1
  },
  "next_step": "Use get_dataflow_structure() to explore a dataflow"
}
```

## Testing

```bash
# Run all tests
uv run pytest

# Run with coverage
uv run pytest --cov=. --cov-report=html

# Run specific test categories
uv run pytest tests/unit/
uv run pytest tests/integration/
uv run pytest tests/e2e/
```

## Known Limitations

### Multi-User Endpoint Isolation

Each MCP session has its own client pool (one `SDMXProgressiveClient` per endpoint it has touched) and its own mismatch-hint registry. STDIO mode uses a single session; HTTP transport uses `Mcp-Session-Id` headers for per-user isolation. Sessions timeout after 30 minutes of inactivity. The session default endpoint is immutable at runtime — set it via the `SDMX_ENDPOINT` env var at server startup.

See `docs/MULTI_USER_CONSIDERATIONS.md` for production deployment details.

## Project Status

| Feature                   | Status      |
| ------------------------- | ----------- |
| SDK upgrade (v1.26.0)     | āœ… Complete |
| Structured outputs        | āœ… Complete |
| Streamable HTTP transport | āœ… Complete |
| Lifespan context          | āœ… Complete |
| Elicitation support       | āœ… Complete |
| Icons & metadata          | šŸ”„ Pending  |
| Documentation             | āœ… Complete |

See `TODO.md` for detailed modernization progress.

## Contributing

Key areas for contribution:

- Additional SDMX provider support
- Enhanced semantic search
- Performance optimization
- Test coverage expansion

## References

- [MCP Specification](https://modelcontextprotocol.io/specification)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [SDMX 2.1 REST API](https://github.com/sdmx-twg/sdmx-rest)
- [Pacific Data Hub](https://stats.pacificdata.org/)

## License

MIT License - See LICENSE file for details.

TDQS

A4.4/5.0

Scored across 20 tools

Disambiguation4/5

Most tools have clearly distinct purposes, but several availability-check tools (get_data_availability, get_code_usage, check_time_availability, probe_data_url) overlap in function and could lead to misselection without careful description reading. The descriptions themselves disambiguate well, so the issue is mild.

Naming Consistency5/5

All tool names follow a consistent snake_case verb_noun pattern (get_, list_, check_, find_, compare_, validate_, build_, probe_, suggest_). No mixed conventions or vague verbs like 'process' or 'do_thing'.

Tool Count4/5

20 tools is slightly heavy but justified for a comprehensive SDMX gateway covering discovery, structure, availability, query construction, metadata, visualization, and endpoint management. Each tool serves a defined role, though some could arguably be merged.

Completeness5/5

The tool surface covers the full SDMX data workflow: discovery (list_dataflows, find_code_usage_across_dataflows), structure inspection, availability checks, query building/validation, URL generation, probing, recovery from empty results, reference metadata, and endpoint management. No obvious dead ends or missing core operations for its stated purpose.

Maintenance

ActivitySlowing
ResponsivenessNo issues