One MCP to rule them all.
MapleStats is one MCP server for publicly available Canadian data. MCP, the Model Context Protocol, is the open standard AI agents use to reach outside tools and data. Connect MapleStats to your AI agent to retrieve precise, well-sourced statistics and reports from 86 sources, including Statistics Canada, the Bank of Canada and CMHC. Never worry about Canadian data retrieval again: focus on what actually matters to you.
One connection
MCP is the standard way to plug data into AI agents. MapleStats puts Canada's scattered agencies and portals behind one connection, in English or French, with the source on every number.
Scripts you can rerun
Any data call can come back as an R, Python, Stata or Julia script that fetches the same numbers straight from the source, so the analysis runs without the agent.
The easiest way: ask your agent One prompt installs MapleStats and connects it. The agent reads the setup steps from the repository and does the rest.
Install the MapleStats MCP server and connect it to this agent. Follow the setup steps in https://github.com/dsanchezp18/maplestats-mcp- Copy the prompt.
- Paste it into Claude Code, Codex, Cursor or any agent that can run commands on your computer.
- Restart the agent when it says so, then ask for data.
Or set it up by handBrowse the 215 tools
Version 0.1.0, beta. Runs on your machine over stdio, with no account or API key.
3
MCPs for the statisticians, keepers of the weights and the margins,
7
for the demographers, counting who comes to stay,
9
for the analysts, doomed to explain the rate…
Okay, perhaps not quite.
Too many places to look, too many interfaces to learn.
As MCP made it easier to connect AI agents to data, we started building narrow servers for narrow pieces of the Canadian data ecosystem: one for Statistics Canada, another for the Bank of Canada, others for open-data portals. Each can be useful. Together they recreate an old Canadian data problem: too many choices before asking a relatively simple question. MapleStats takes the opposite approach, one MCP for Canadian open data.
Centralize the interface, not the data.
The server keeps no copy. It calls each source directly, so every number comes from its publisher.
One discovery layer, many adaptors.
Each agency keeps its own adaptor underneath. The agent sees one search across all of them.
Start from the question, not the portal.
The agent does not need to know whether the answer is a StatCan table, a Valet series, a CMHC dataset or a CKAN resource.
Case studies
What can you do with it
Eight questions, from eight kinds of work, each answered with MapleStats tool calls. Here is one of them: a macroeconomist reading the yield curve.
Macroeconomists
When the yield curve turns upside down
The gap between the 10-year and 2-year Government of Canada bond yields, every business day since 2001. When it drops below zero, markets expect rates to fall, usually because they expect the economy to slow. It inverted in 2007, 2019 to 2020 and 2022 to 2024.
Show the data
| Year | Average | Lowest month | Highest month |
|---|---|---|---|
| 2001 | 117 bp | 41 bp | 213 bp |
| 2002 | 169 bp | 139 bp | 225 bp |
| 2003 | 155 bp | 124 bp | 188 bp |
| 2004 | 165 bp | 126 bp | 199 bp |
| 2005 | 88 bp | 23 bp | 131 bp |
| 2006 | 18 bp | 6 bp | 32 bp |
| 2007 | 9 bp | -5 bp | 32 bp |
| 2008 | 95 bp | 54 bp | 171 bp |
| 2009 | 200 bp | 167 bp | 218 bp |
| 2010 | 169 bp | 139 bp | 221 bp |
| 2011 | 141 bp | 110 bp | 158 bp |
| 2012 | 76 bp | 65 bp | 100 bp |
| 2013 | 115 bp | 76 bp | 157 bp |
| 2014 | 118 bp | 84 bp | 150 bp |
| 2015 | 97 bp | 77 bp | 117 bp |
| 2016 | 69 bp | 49 bp | 96 bp |
| 2017 | 69 bp | 34 bp | 95 bp |
| 2018 | 29 bp | 8 bp | 51 bp |
| 2019 | -0 bp | -18 bp | 14 bp |
| 2020 | 24 bp | -12 bp | 49 bp |
| 2021 | 88 bp | 47 bp | 124 bp |
| 2022 | -13 bp | -87 bp | 58 bp |
| 2023 | -91 bp | -123 bp | -71 bp |
| 2024 | -33 bp | -69 bp | 17 bp |
| 2025 | 63 bp | 36 bp | 81 bp |
| 2026 | 70 bp | 61 bp | 84 bp |
The other seven
- Statisticians A bachelor's degree, province by province
- Demographers Who comes to stay: Edmonton and Calgary
- Urban planners Canada is building up, not out
- Microeconomists Low income across immigrant generations
- Marketers What a rewards card costs
- Scientists Machine learning at the patent office
- Analysts The policy rate since 2015
Sources
Where the data comes from
Federal agencies answer most national questions; provincial agencies and open-data catalogues add the local detail. Every result comes straight from its publisher.
- 30
- federal and national sources
- 56
- provincial, territorial and city sources
- 12/13
- provinces and territories with local data
- 215
- tools behind one connection
How it works
From a question to a cited number
A connected agent sees three tools: plan_query, search_tools and call_tool. The other 214 stay out of its context until a search finds them. Every output below is real. The server produced the plan, the search and the scripts when this page was built; the data call was recorded on 27 September 2026.
-
01
Plan
plan_queryThe planner reads the question and lists the agencies to ask, in order, with the caveats on combining them. Places it recognizes add their local portals.
plan_queryRun when this page was builtQuestion
How have rents and interest rates moved in Calgary since 2020?
Plan
Housing: starts, rents, prices, mortgages
cmhc_CMHC starts, completions, rents and vacancy by centrelist_ categories cmhc_pull the CMHC table for the place and periodget_ table_ data wds_StatCan New Housing Price Index, building permitssearch_ cubes boc_Bank of Canada mortgage and policy ratessearch_ series statcan_census shelter cost and tenure by areacensus_ profile_ get_ data
Caveat CMHC reports by census metropolitan area and centre; StatCan price indexes are by CMA too, but the two define some areas differently, so name the geography each figure uses.
Interest rates, exchange rates and markets
boc_find the Valet series (e.g. FXUSDCAD, V39079)search_ series boc_pull the series for the periodget_ observations
Calgary (city)
socrata_search with portal='calgary'search_ datasets
-
02
Find
search_toolsSearch ranks the catalogue against a plain-language query and returns the top 5. French works because every tool carries French keywords. The panel runs the server's own index in your browser, so type your own query.
search_toolsRuns in your browserTry
- 1boc_
search_ Bank of CanadaSearch Bank of Canada Valet's ~16,000 statistical series by keyword.series - 2boc_
get_ Bank of CanadaGet the label and description for one Bank of Canada Valet series.series - 3boc_
get_ Bank of CanadaGet observations for one or more Bank of Canada Valet series.observations - 4boc_
get_ Bank of CanadaGet a Bank of Canada Valet group's description and member series.group - 5boc_
search_ Bank of CanadaSearch Bank of Canada Valet's series groups by keyword.groups
Top 5 of 214 searchable tools, ranked by the same BM25 index search_tools uses.
- 1boc_
-
03
Fetch
call_toolThe agent calls the tool by name. The result is a typed object, and its provenance block, marked here, gives the exact upstream URL and the time of the query.
call_toolRecorded 27 September 2026Request
{"name": "boc_get_observations", "arguments": {"series_names": ["V39079"], "recent": 3}}Result
Target for the overnight rate (business daily) V39079Date Value 24 September 2026 2.25 23 September 2026 2.25 22 September 2026 2.25 Provenance
- Queried
- 27 September 2026, 17:00 UTC
- Result type
boc.ObservationsResult
Show the full response as JSON
{ "series": { "V39079": {"name": "V39079", "label": "Target for the overnight rate (business daily)", "description": "Also called the policy interest rate, the average rate that the Bank of Canada wants to see in the market for overnight money market financing. (V39079)"} }, "observations": [ {"ref_date": "2026-09-24", "values": {"V39079": 2.25}}, {"ref_date": "2026-09-23", "values": {"V39079": 2.25}}, {"ref_date": "2026-09-22", "values": {"V39079": 2.25}} ], "provenance": { "source": "boc", "url": "https://www.bankofcanada.ca/valet/observations/V39079/json", "queried_at": "2026-09-27T17:00:48.227537Z", "as_of": null, "freshness": null, "coverage": null, "limits": null, "cached": false, "schema_name": "boc.ObservationsResult", "reproduce": "For R, Python, Stata and Julia scripts that fetch and clean this data, call reproduce_code with this tool's name and arguments." }} -
04
Reproduce
reproduce_codeThe same call becomes an R, Python, Stata or Julia script that downloads, checks and cleans the data, so the analysis does not depend on the agent.
reproduce_codeRun when this page was builtRequest
{"tool_name": "boc_get_observations", "arguments": {"series_names": ["V39079"], "recent": 3}}R
# ============================================================ # Bank of Canada Valet: V39079 # Purpose: Fetch the data behind MapleStats MCP's boc_get_observations # (exact: Valet request rebuilt from the tool's arguments) # Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 # Outputs: data/raw/valet_observations.json; the prepared table as `data` # ============================================================ # 0. Setup ---- library(dplyr) library(httr2) library(janitor) library(jsonlite) library(lubridate) library(readr) library(stringr) library(tibble) library(tidyr) dir.create("data/raw", recursive = TRUE, showWarnings = FALSE) # 1. Read inputs ---- response <- request("https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3") |> req_perform() writeLines(resp_body_string(response), "data/raw/valet_observations.json") payload <- fromJSON("data/raw/valet_observations.json", flatten = TRUE) data <- as_tibble(payload[["observations"]]) # 2. Check inputs ---- stopifnot("https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 returned no rows" = nrow(data) > 0) # 3. Prepare data ---- data <- data |> clean_names() # Valet nests each series as <series>.v; make one row per date and series. data <- data |> pivot_longer(-d, names_to = "series", values_to = "value") |> mutate( series = str_remove(series, "_v$") |> str_to_upper(), value = as.numeric(value), date = ymd(d) ) |> select(date, series, value) # Standard cleaning: trimmed text, empty strings as missing, and numbers # stored as text converted to numbers. data <- data |> mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |> type_convert()Python
# ============================================================ # Bank of Canada Valet: V39079 # Purpose: Fetch the data behind MapleStats MCP's boc_get_observations # (exact: Valet request rebuilt from the tool's arguments) # Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 # Outputs: data/raw/valet_observations.json; the prepared table as `data` # ============================================================ # %% 0. Setup import json import re import unicodedata from pathlib import Path import httpx import polars as pl RAW_DIR = Path("data/raw") RAW_DIR.mkdir(parents=True, exist_ok=True) raw_path = RAW_DIR / "valet_observations.json" # %% 1. Read inputs with httpx.Client( http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}, ) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3') response.raise_for_status() raw_path.write_bytes(response.content) payload = json.loads(raw_path.read_text(encoding="utf-8")) records = payload['observations'] data = pl.json_normalize(records, strict=False) # %% 2. Check inputs assert data.height > 0, "https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 returned no rows" # %% 3. Prepare data # Valet nests each series as <series>.v; make one row per date and series. data = data.unpivot(index="d", variable_name="series", value_name="value") data = data.with_columns( pl.col("d").str.to_date().alias("date"), pl.col("series").str.replace(r"\.v$", ""), pl.col("value").cast(pl.Float64, strict=False), ).select("date", "series", "value") # Standard cleaning: snake_case names without accents (PÉRIODE -> periode, # referenceNumber -> reference_number, as janitor does in R), trimmed text, # empty strings as missing. Names that clean alike are numbered as janitor # numbers them (Indicator, indicator -> indicator, indicator_2). clean_names = [ re.sub( r"[^0-9a-z]+", "_", re.sub( r"([a-z0-9])([A-Z])", r"\1_\2", unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(), ).lower(), ).strip("_") for column in data.columns ] while len(set(clean_names)) < len(clean_names): name_counts = {} numbered = [] for name in clean_names: name_counts[name] = name_counts.get(name, 0) + 1 count = name_counts[name] numbered.append(name if count == 1 else f"{name}_{count}") clean_names = numbered data = data.rename(dict(zip(data.columns, clean_names))) data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))Stata
* ============================================================ * Bank of Canada Valet: V39079 * Purpose: Fetch the data behind MapleStats MCP's boc_get_observations * (exact: Valet request rebuilt from the tool's arguments) * Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 * Outputs: data/raw/valet_observations.json; the prepared table as `data` * ============================================================ version 18 clear all set more off * 0. Setup capture mkdir "logs" capture log close log using "logs/boc_get_observations.log", replace capture mkdir "data" capture mkdir "data/raw" * 1. Read inputs * Stata reads no JSON or HTML and truncates long column names, so its * built-in Python (Stata 16+) fetches, filters and writes a CSV. Point * Stata at a Python with these packages first: python set exec <path>. python: import json from pathlib import Path import httpx import polars as pl RAW_DIR = Path("data/raw") RAW_DIR.mkdir(parents=True, exist_ok=True) raw_path = RAW_DIR / "valet_observations.json" with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3') response.raise_for_status() raw_path.write_bytes(response.content) payload = json.loads(raw_path.read_text(encoding="utf-8")) records = payload['observations'] data = pl.json_normalize(records, strict=False) nested = [name for name, dtype in data.schema.items() if dtype.is_nested()] data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested) data.write_csv(RAW_DIR / "valet_observations_prepared.csv") end import delimited "data/raw/valet_observations_prepared.csv", clear varnames(1) encoding("utf-8") * 2. Check inputs assert _N > 0 * 3. Prepare data * One column per series (<series>_v), one row per date. generate date = date(d, "YMD") format date %td drop d * Standard cleaning: lower-case names, trimmed text, and numbers stored as * text converted (destring leaves genuinely non-numeric text alone). * rename *, lower stops at a clash (Indicator next to indicator), so names * that lower-case alike are numbered as janitor numbers them (indicator, * indicator_2), then renamed in one group rename, which allows swaps. local names foreach var of varlist _all { local names `names' `=strlower("`var'")' } local dups : list dups names while "`dups'" != "" { local numbered local before foreach name of local names { local count 1 foreach earlier of local before { if "`earlier'" == "`name'" local ++count } local before `before' `name' if `count' > 1 { local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'" } local numbered `numbered' `name' } local names `numbered' local dups : list dups names } local old_names local new_names local i 0 foreach var of varlist _all { local ++i local name : word `i' of `names' if "`name'" != "`var'" { local old_names `old_names' `var' local new_names `new_names' `name' } } if "`old_names'" != "" { rename (`old_names') (`new_names') } quietly ds, has(type string) local text_vars `r(varlist)' foreach var of local text_vars { replace `var' = strtrim(`var') } destring, replace log closeJulia
# ============================================================ # Bank of Canada Valet: V39079 # Purpose: Fetch the data behind MapleStats MCP's boc_get_observations # (exact: Valet request rebuilt from the tool's arguments) # Inputs: https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 # Outputs: data/raw/valet_observations.json; the prepared table as `data` # ============================================================ # 0. Setup using DataFrames using Downloads using JSON3 using Tables using TidierData mkpath("data/raw") # 1. Read inputs Downloads.download("https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3", "data/raw/valet_observations.json") payload = JSON3.read(read("data/raw/valet_observations.json", String)) records = payload["observations"] data = DataFrame(Tables.dictrowtable(records)) # 2. Check inputs @assert nrow(data) > 0 "https://www.bankofcanada.ca/valet/observations/V39079/json?recent=3 returned no rows" # 3. Prepare data # Standard cleaning: snake_case names, trimmed text, empty strings as missing. data = @chain data begin @clean_names end data = mapcols( col -> eltype(col) <: Union{Missing, AbstractString} ? [ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col, data, )
Connect
Add it to your client
Your client starts the server with uv when it needs it. There is nothing to host and no port to open.
Claude Code
Run this once in a terminal. --scope user makes the server available in every project; leave it out to add it to the current project only.
claude mcp add --scope user maplestats -- uvx maplestats-mcp
Claude Desktop
In Claude Desktop, open Settings, then Developer, then Edit Config. Add the server to the file that opens, save it and restart Claude.
{
"mcpServers": {
"maplestats": {
"command": "uvx",
"args": ["maplestats-mcp"]
}
}
}
The file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows.
Cursor
The link opens Cursor with the entry filled in. To add it by hand, put it in ~/.cursor/mcp.json for every project, or in .cursor/mcp.json for one project.
{
"mcpServers": {
"maplestats": {
"command": "uvx",
"args": ["maplestats-mcp"]
}
}
}
VS Code
To add it by hand, note that VS Code uses a servers key, not mcpServers. Put this in .vscode/mcp.json in a workspace, or run MCP: Open User Configuration from the Command Palette to add it for every workspace.
{
"servers": {
"maplestats": {
"type": "stdio",
"command": "uvx",
"args": ["maplestats-mcp"]
}
}
}
Codex CLI
Add it from the terminal, or write the same entry into ~/.codex/config.toml yourself.
codex mcp add maplestats -- uvx maplestats-mcp
[mcp_servers.maplestats]
command = "uvx"
args = ["maplestats-mcp"]
Gemini CLI
Add it from the terminal (-s user for every project), or write the entry into ~/.gemini/settings.json next to any settings already there.
gemini mcp add -s user maplestats uvx maplestats-mcp
{
"mcpServers": {
"maplestats": {
"command": "uvx",
"args": ["maplestats-mcp"]
}
}
}
Other clients
Most MCP clients accept this mcpServers entry. If you installed the command with uv tool install or pip, use "command": "maplestats-mcp" and drop args.
{
"mcpServers": {
"maplestats": {
"command": "uvx",
"args": ["maplestats-mcp"]
}
}
}
Acknowledgements
MapleStats builds on the open work of many others. It learned from the many MCP servers for Canadian and government data that came before it, and much of its architecture and inspiration comes from EcuDataMCP, my other project, an MCP server for Ecuador's open government data.
Existing open-source packages helped too, above all the R packages of Jens von Bergmann (mountainMath) and their co-authors:
cmhcThe CMHC module was checked against its reverse-engineering of CMHC's Housing Market Information Portal.cansimThe R code that reproduce_code writes for Statistics Canada tables and vectors uses it.cancensusA model for census data access in R.canivtReads Beyond 20/20 files. Census tables and Borealis deposits that exist only in that format are routed to it.
Thank you to the developers who built and shared these tools, and to everyone who publishes Canadian data in the open.
For other ways to get Canadian data, some of these packages among them, see the alternatives on the About page.
One MCP for the Agent to find them, one MCP to bring them all together.
Show the data
| Subject | Tools |
|---|---|
| Statistics: statistics and census | 62 |
| Provinces and cities: open-data catalogues, provincial, municipal | 47 |
| Money and business: money, prices and public finance, business, IP and competition | 31 |
| Land and energy: agriculture and food, environment and hazards, energy, geography, transport and safety | 31 |
| People: health, housing, immigration | 22 |
| Parliament: parliament, law and elections | 20 |