@cyanheads/unhcr-refugees-mcp-server
Query UNHCR refugee, IDP, and stateless populations, asylum decisions, returns, and resettlement via MCP. STDIO or Streamable HTTP.
8 Tools (+1 opt-in)
Overview
Refugee and displacement statistics from the UNHCR Refugee Data Finder: refugee, asylum-seeker, IDP, and stateless populations since 1951, asylum applications and decisions since 2000, and returns and resettlement since 1959. Pull annual figures by country of origin and asylum, and run SQL over large results. No API key required.
Tools
unhcr_dataframe_drop is registered only when UNHCR_DATAFRAME_DROP_ENABLED=true; the other eight tools are always advertised.
Dataframe workflow
- A
unhcr_get_* call whose full result exceeds limit, or that sets stage: true, stages every row as a df_XXXXX_XXXXX table and returns its handle in dataset (nowcast_dataset for unhcr_get_population's nowcast).
unhcr_dataframe_describe lists the staged tables with their columns, source call, and expiry. Listing is off over HTTP with MCP_AUTH_MODE=none, so there describe each table by the name in dataset or nowcast_dataset.
unhcr_dataframe_query runs one read-only DuckDB SELECT across them; register_as saves the result as a new table for the next query.
- Tables expire after
UNHCR_DATASET_TTL_SECONDS (24 hours by default). With UNHCR_DATAFRAME_DROP_ENABLED=true, unhcr_dataframe_drop removes one sooner. Once a tenant's staged rows pass 1,000,000, the oldest tables are evicted first, and the call that pushed the total over names them in evicted.
Dataframes run on DuckDB's native binding, which the npm and Docker installs carry. The Claude Desktop .mcpb bundle ships without it, so there the data tools answer inline only. Set CANVAS_PROVIDER_TYPE=none to turn dataframes off anywhere.
Agent skill
skills/unhcr-refugees-guide is a published Agent Skill. It covers the checks to make before summing or comparing UNHCR figures: which columns a total adds, stateless people under origin XXA, the two Palestine refugee series, the nowcast's missing origin, bureau and EU totals in SQL, filters that drop whole countries, units that switch between years, demographic shares, and footnotes. Plugin installs and the .mcpb bundle pick it up from the skills/ folder, and MCP clients that support skills read it as skill://unhcr-refugees-guide/SKILL.md.
Capability reference
The five unhcr_get_* tools share one contract:
- Scope:
origin and asylum take ISO3 codes, case-insensitive, as a string or a list, up to 250 distinct codes each, enough to list every country. ISO2 codes are rewritten to ISO3; UNHCR's own codes and country names are rejected. Each listed code returns its own rows, and an omitted dimension is summed into one row unless expand (origin, asylum, or both) lists every country. year_from / year_to default to the dataset's span and are clamped to it.
- Rows: the full result is sorted by
sort_by, then cut to limit (default 25, cap 2,000). A larger limit is served at 2,000 with a notice naming the requested and effective values, even for an empty or small result; with UNHCR_CLAMP_LIMITS=false it is rejected instead. Where dataframes are on, the full set of a larger result is staged, and stage: true stages a result that fits too.
- Output:
total_rows, complete (false when UNHCR_MAX_ROWS stopped the fetch), measure (stock or flow), applied_scope, latest_year, dataset when staged, data_notes, and attribution.
- Errors:
unknown_country_code, invalid_year_window, year_out_of_coverage, conflicting_scope, too_many_country_codes, and the retryable upstream_busy, which carries retryAfter.
unhcr_list_reference tool
topic: countries, regions, coverage, population_types, or asylum_codes. With countries, name_contains keeps countries whose names contain every word given, or whose ISO3, ISO2, or UNHCR code equals one; there is no fuzzy matching
coverage gives each dataset's first_year, latest_year, and measure, plus the month of the current nowcast
- Countries, regions, and coverage come from the live API and are cached for 24 hours; population types and asylum codes are static, and asylum codes UNHCR's methodology does not define carry
documented: false
unhcr_get_population tool
- Year-end stocks
refugees, asylum_seekers, oip, idps, stateless, ooc, and hst, plus returned_refugees and returned_idps, which are flows during the year; sort_by takes any of them or year
unrwa_refugees (Palestine refugees registered with UNRWA) and idmc_conflict_idps (IDMC's conflict-IDP estimate) sit beside a row when that series has one and are never added into UNHCR's counts. Matching UNHCR footnotes come back too, up to 20, with footnotes_total; caveats on the inline rows come first
include_nowcast: true appends UNHCR's current-year monthly estimate of refugees and asylum-seekers by asylum country, sorted by refugees or asylum_seekers when sort_by names one, cut to limit with nowcast_total, and staged as its own dataframe (nowcast_dataset) when it overflows or stage is set; it is skipped when origin lists codes
unhcr_get_demographics tool
population_types filters to REF, ASY, OIP, IDP, STA, OOC, HST, RET, or RDP; sort_by takes year or total
- Each row is one population type:
total, fourteen sex × age bands (female_0_4 … female_60_plus, female_unknown_age, female_total, and the male_* twins), disaggregated, and sex_disaggregated_share (0–1). Bands are null where UNHCR has no breakdown
- Totals come from a separate collection and need not match
unhcr_get_population; matching footnotes come back as there
unhcr_get_asylum_applications tool
split_by names which of authority, stage, and decision_level stay separate rows (default ["stage"]; [] gives one total per year, scope, and unit). stages filters before summing, e.g. ["N"] for new applications; sort_by takes year or applied
- Each row lists the
authorities, stages, and decision_levels codes summed into it, its unit (persons or cases), and applied. Cases are never added to persons
unhcr_get_asylum_decisions tool
split_by names which of authority and decision_level stay separate rows (default [], all summed, as UNHCR does for its rates). decision_levels filters before summing, e.g. ["FI"] for first instance
- Each row carries
recognized, complementary_protection, rejected, otherwise_closed, total_decisions, substantive_decisions, and a unit, plus refugee_recognition_rate and total_protection_rate as percentages of substantive decisions. A rate is null when that denominator is 0 or null
sort_by takes year or a count (total_decisions, substantive_decisions, recognized, rejected); rates are not sortable
unhcr_get_solutions tool
- Flows
returned_refugees, resettlement, naturalisation, and returned_idps; sort_by takes any of them or year. Matching footnotes come back as with population
- The asylum country means something different per column: the country refugees returned from, the country they were resettled to, the country that naturalised them. IDP returns sit on the origin country itself
unhcr_dataframe_describe tool
- Optional
name (df_XXXXX_XXXXX); omit it to list every staged table, newest first. Over HTTP with MCP_AUTH_MODE=none listing is off and name is required
- Each entry gives
source_tool, query_params, created_at, expires_at, row_count, complete, incomplete_sources, providers, and column_schema. Saved descendants retain original incomplete-source handles after their ancestors expire or are evicted. Staged tables add origin_unhcr_region, origin_unsd_region, asylum_unhcr_region, and asylum_unsd_region columns the inline rows omit
unhcr_dataframe_query tool
sql is one DuckDB SELECT of at most 20,000 characters; joins, aggregates, window functions, and CTEs work. Writes, DDL, PRAGMA, ATTACH, file references, external-data functions, engine introspection (current_setting, current_schema, current_schemas, in_search_path), dynamic table lookup (query_table), and system catalogs are rejected with a typed reason. query() accepts only a single SQL string literal, checked by the same gate
row_limit caps the rows the query materializes, and row_count_capped flags a capped result. It defaults to 1,000, or to the canvas row cap (CANVAS_DEFAULT_ROW_LIMIT, 10,000 by default) when that is lower, and a larger value returns the canvas row cap with a notice naming requested and effective values. register_as saves the whole result under a new df_XXXXX_XXXXX name with a fresh TTL. It counts toward the 1,000,000-row staging budget, and a result that alone exceeds it fails register_as_too_large
preview sets the rows returned inline: default 25, at most 1,500 or row_limit, whichever is lower, with the same notice above the cap. preview: 0 returns the columns and counts with no rows. With UNHCR_CLAMP_LIMITS=false, a row_limit above the canvas row cap or a preview above 1,500 fails validation instead
SUM and COUNT values come back as strings (BIGINT). attribution credits the third-party series of every table the SQL reads
complete and incomplete_sources describe upstream coverage independently of display truncation: aggregates, joins and saved descendants remain partial if an original source was partial
- Every returned value is whole on both surfaces. Results with plain labels and cells use Markdown tables; values needing exact whitespace, control characters, nested structures or Markdown syntax use fenced JSON
unhcr_dataframe_drop tool
name (df_XXXXX_XXXXX); returns dropped: false when nothing matched, so repeating a call is safe
- Registered only when
UNHCR_DATAFRAME_DROP_ENABLED=true; otherwise the per-table TTL reclaims staged tables
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
UNHCR-specific:
- Keyless client for the API behind UNHCR's Refugee Data Finder (
api.unhcr.org/population/v1). The API ignores unknown parameters and lone year bounds rather than rejecting them, so the server sends only allowlisted parameters, always pairs the year bounds, and pins ISO3 country matching (cf_type=ISO)
- Shared request queue: paced request starts (
UNHCR_REQUESTS_PER_SECOND), at most two in flight, a cooldown after a 429, identical requests collapsed into one, a response cache, and a 45-second budget per call
- UNHCR's reading of the numbers: stocks and flows labelled as such, UNHCR's
"-" returned as null rather than zero, cases never added to persons, recognition rates over UNHCR's substantive-decision denominator
- UNHCR rounds counts below 5 (below 10 for asylum decisions) to the nearest multiple of 5, so small values are approximate
- Every result carrying UNHCR figures cites the "UNHCR Refugee Population Statistics Database" (CC BY 4.0) with a link to UNHCR's Terms of Use for Datasets. UNRWA and IDMC figures are those providers' own series, carried through UNHCR's API;
attribution.providers credits them whenever they appear, since their own conditions may apply
Agent-friendly output:
- Provenance on every data result:
applied_scope echoes the codes, year window, and ISO2 rewrites actually sent, alongside latest_year, measure, complete, and attribution
- Caveats as data:
data_notes say how to read each result (stock or flow, rounding, what null means), and matching UNHCR footnotes arrive with rows_matched
- Typed failures with recovery hints:
unknown_country_code suggests the ISO3 for a UNHCR code, year_out_of_coverage names the dataset's span, upstream_busy carries retryAfter
- Nothing trimmed silently: year and limit clamps, empty-result hints, truncation, and where the full set was staged all arrive as a notice in the response
Getting started
Connect to the hosted instance, no install:
Every caller of the hosted instance shares one UNHCR request queue and one dataframe workspace, so unhcr_dataframe_describe takes a table's exact name there; listing is off.
Or run it locally:
For other clients, add this to the MCP config file:
UNHCR's API is keyless, so no key or account is needed, and every variable under Configuration is optional. For Docker or a source build, see Running the server.
Configuration
See .env.example for the full list of optional overrides.
Running the server
Local development
Requires Bun v1.4.0 or higher (or Node.js v24+).
Docker
The image defaults to HTTP transport, stateless session mode, and logs to /var/log/unhcr-refugees-mcp-server. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/unhcr-refugees-mcp-server:latest"]. To build locally, docker build -t unhcr-refugees-mcp-server .; OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
Project structure
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catch in tool logic
- Use
ctx.log for logging, ctx.state for storage
- Register new tools in
buildToolDefinitions() in src/mcp-server/tools/definitions/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Contributing
Issues are welcome. Run checks and tests before submitting:
License
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.
The data belongs to UNHCR and is published under CC BY 4.0 and UNHCR's Terms of Use for Datasets. This project is independent of UNHCR and not endorsed by it.